0
\$\begingroup\$

I'm writing an app meant to have similar functionality to LucidChart - drawing shapes/diagrams. I'm using React with Typescript (Create React App) and am trying to follow OOP/polymorphic patterns I've been learning about from watching videos by Uncle Bob Martin and elsewhere on the internet - factory, singleton, state patterns, etc. I'm mainly writing this app to learn more about OOP and these kinds of design patterns.

The architecture of my app is one that I've made up as I've been trying to use these OOP patterns and trying to get them to work with React. A simplified explanation of the main design is as follows:

  1. The main component stores all the state of the app, which are my my shape classes (and relations, operation, other classes in the non-simplified version of app).
  2. The abstract shape class has an abstract GetComponent():JSX.Element that each concrete class has to implement and that the main class calls in its render() method.
  3. In order to change the state of the shapes using setState() so that react will detect the state change and rerender, I am passing down an object containing setState-calling callbacks to the Shape instances (as would be done in a standard React architecture), so that they can refresh the app's react state with their new state after updating it internally - this can be seen in the MoveToPosition() implementations below. (Really this is done in separate Operation classes which operate on the Shape classes - I just tried to simplify the code for the example here - but the idea is the same: a non-React class changes the React state with a callback passed from the React component).

Code:

//Manchas.tsx

import React from "react";
import { Shape, Rectangle, Circle } from "./shapes";
import { StateUtilities } from "./state_utilities";
import "./Manchas.css";
import logger from "./logger";


export type ManchasState = {
  shapes: Shape[];
};

export class Manchas React.Component<{}, ManchasState> {
  constructor(props = {}) {
    super(props);

    const stateUtilities = {
      RefreshShapesState: this.UpdateShapes,
    };
    //dummy shapes just in example - really these are created with
    //factories at runtime
    const dummy1 = new Rectangle(2000, 2000, stateUtilities);
    const dummy2 = new Rectangle(1, 1, stateUtilities);
    const dummy3 = new Circle(1000, 1000, stateUtilities);
    this.state = {
      shapes: [dummy1, dummy2, dummy3],
    };
    this.UpdateShapes = this.UpdateShapes.bind(this);
  }

  UpdateShapes() {
    // called by Shape instances (or Operation instances in real app) after they
    // modify their own state, just so React state is changed to trigger rerender
    logger.Debug("Updating shapes state");
    this.setState({ shapes: this.state.shapes });
  }


  render() {
    return (
      <div className="App">
        {this.state.shapes.map((shape, index) => {
          return shape.GetComponent(index);
        })}
      </div>
    );
  }
}

export default Manchas;

//shapes.tsx
import React from "react";
import config from "./config";
import { Position } from "./geometry";
import { ConvertScreenNumberIntoAbsolutePx } from "./utilities";
import { StateUtilities } from "./state_utilities";

export abstract class Shape {
  public abstract GetComponent(index: number): JSX.Element;
  public abstract MoveToPosition(destinationPosition: Position): void;
}

export class Rectangle extends Shape {
  private height: number;
  private width: number;
  private topLeftCoordinate: Position;
  private stateUtilities: StateUtilities;

  constructor(
    topLeftX: number,
    topLeftY: number,
    stateUtilities: StateUtilities
  ) {
    super();
    this.topLeftCoordinate = { X: topLeftX, Y: topLeftY };
    this.height = config.GetDefaultRectangleHeight();
    this.width = config.GetDefaultRectangleLength();
    this.stateUtilities = stateUtilities;
  }

  MoveToPosition(destinationPosition: Position): void {
    // some operation will call this method
    this.topLeftCoordinate = destinationPosition;
    this.stateUtilities.RefreshShapesState();
  }

  GetComponent(index: number): JSX.Element {
    return (
      <RectangleFC
        key={index}
        topLeftCoordinate={this.topLeftCoordinate}
        height={this.height}
        width={this.width}
      />
    );
  }
}

type RectangleProps = {
  topLeftCoordinate: Position;
  height: number;
  width: number;
};

const RectangleFC = ({
  topLeftCoordinate,
  height,
  width,
}: RectangleProps): JSX.Element => (
  <div
    style={{
      position: "absolute",
      left: `${ConvertScreenNumberIntoAbsolutePx(topLeftCoordinate.X)}px`,
      top: `${ConvertScreenNumberIntoAbsolutePx(topLeftCoordinate.Y)}px`,
      width: `${ConvertScreenNumberIntoAbsolutePx(width)}px`,
      height: `${ConvertScreenNumberIntoAbsolutePx(height)}px`,
      border: "3px solid black",
      zIndex: 3,
    }}
  ></div>
);

//CIRCLE
export class Circle extends Shape {
  private diameter: number;
  private centerCoordinate: Position;
  private stateUtilities: StateUtilities;
  constructor(
    centerX: number,
    centerY: number,
    stateUtilities: StateUtilities
  ) {
    super();
    this.stateUtilities = stateUtilities;
    this.diameter = config.GetDefaultCircleDiameter();
    this.centerCoordinate = { X: centerX, Y: centerY };
  }

  MoveToPosition(destinationPosition: Position): void {
    // some operation will call this method
    this.centerCoordinate = destinationPosition;
    this.stateUtilities.RefreshShapesState();
  }

  GetComponent(index: number): JSX.Element {
    return (
      <CircleFC
        key={index}
        centerX={this.centerCoordinate.X}
        centerY={this.centerCoordinate.Y}
        diameter={this.diameter}
      />
    );
  }
}

type CircleProps = {
  centerX: number;
  centerY: number;
  diameter: number;
};

const CircleFC = ({ centerX, centerY, diameter }: CircleProps): JSX.Element => {
  const radius = diameter / 2;
  const left = centerX - radius;
  const top = centerY - radius;
  return (
    <div
      style={{
        position: "absolute",
        left: `${ConvertScreenNumberIntoAbsolutePx(left)}px`,
        top: `${ConvertScreenNumberIntoAbsolutePx(top)}px`,
        width: `${ConvertScreenNumberIntoAbsolutePx(diameter)}px`,
        height: `${ConvertScreenNumberIntoAbsolutePx(diameter)}px`,
        border: "3px solid black",
        borderRadius: 90,
        zIndex: 3,
      }}
    ></div>
  );
};

I have my shapes as their own Shape classes as opposed to React components as I want to write the application as one that uses polymorphism and OOP design patterns in a traditional sense. Therefore I want to use my own non-React vanilla TypeScript classes as opposed to trying to force my components to inherit from each other or implement interfaces which seems like an antipattern and not how React is supposed to work (I believe this link supports this belief).

Concerns/Questions about the architecture:

  • Passing down setState-calling callbacks down to my Shape classes feels very ugly and also and is also leading to bugs when I have to call several of these setState-calling functions one after another within the same function where the state isn't updating in the way I want it to. What I think I want is to not have to pass this all down and for the shapes to not have to explicitly call these functions - if changes to the state of the Shape instances, which are held in the React's state, were just detected and triggered rerenders. The idea of using Redux seems like it adds code that doesn't really fit into my architecture thus far - having to write reducers and dispatch actions seems like way too much if I want my Shape classes to update their own state internally.
  • Is there a better model for modifying the state of the app? How would an app like this be structured if it was a non-React or non-browser-based app written in Java for example?
  • Is trying to make this app which tries to use these traditional OOP patterns work with React a bad idea? How else would you make an application like this without using this kind of polymorphism?

Feedback on the above or any other aspects of the code would be appreciated! Thanks!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.