When you should use Typescript inheritance in your React web app

13
minutes
Mis à jour le
10/5/2020

Share this post

Find out how Typescript gives you the benefits of object-oriented programming, allowing you to take advantage of polymorphism in your web app.

#
Front-end
#
React
#
Typescript

Get an understanding of object-oriented programming in a React Typescript application with a simple live example and its source code.

The code used for the example application is available on Github and you can see a live demo of the result here.

Why Object-Oriented Programming

OOP (object-oriented programming) will be familiar to anyone who has used languages such as Java. As the name suggests, software is organised around "objects" which can allow for easier code reuse, maintainability, and flexibility. If OOP is a new concept for you, I recommend reading this article (3 minute read) then coming back.

As I will show, utilising this concept in your web app can make it easier to add new features, reduce code duplication, make it easier to catch bugs, and simplify/remove conditional logic. 

Firstly, let me be clear that we should not use inheritance in React components themselves - this is actually discouraged in favour of using composition. Rather, we define our own Typescript classes to encapsulate behaviour that perhaps shouldn't belong in a React component.

Note: I am not saying that this structure or method is always the best way of laying out your application. This is simply a tool to add to your kit, so you can better evaluate options when it comes to planning your project. There are many paradigms and patterns that have their own unique advantages.

 

Separation

The MVC (model view controller) pattern is popular across software with a user interface. Libraries such as ReactJS and VueJS focus on the 'View' part of your application, leaving it up to you to decide how to handle the underlying model. Keeping the model and view separate has many advantages, including easier code reuse and testing. You can read more about MVC here.

In React specifically, making your UI components as 'dumb' as possible by moving logic outside can make them easier to test.

When you have complex behaviour in your web app that doesn't seem part of the 'view', you can use OOP principles to better encapsulate and separate logic by bringing it out into its own classes. In the example I provide, the different brushes in the painting application are separated into their own classes, providing many benefits you will see later.

Simplifying Conditional Logic

Pragmatism should take precedence over what random people on the internet tell you to do with your code. Apply this only where it is actually useful, and keep it in mind as a refactoring option as conditional logic grows in your codebase.

There are cases where lengthy conditional logic (such as nested if statements) gets confusing and difficult to modify without mistakes, errors, or edge cases. Perhaps a long switch statement 'smells bad' (check out the Bad code smells taxonomy).

Object oriented programming brings the benefits of polymorphism to your web app. Polymorphism is a wonderful thing, and gives you conditional logic (such as a switch statement) for free. I focus here in particular on subtype polymorphism.

This is one useful way of refactoring conditional logic, and you can read about other methods here.

Live Example

Try out the demo if you haven't already.

React Typescript drawing app

In the next sections we'll take a look through some of the code and see how polymorphism helped here.

A Naïve Solution

Here's what we want to achieve: a drawing canvas with multiple brushes that have different behaviour. 

We start by making a React component to hold the canvas, taking a prop that tells us which brush is selected. Conditional statements are used to decide which behaviour is triggered on touch events. 

(I'm using the library InteractJS to handle touch input, the following syntax should be fairly self-explanatory. Types and brush actions have been omitted for brevity.)

 


export function BadCanvas(props) {
  useEffect(() => {
    interact('#bad-canvas')
      .on("tap", (event) => {
        if (props.brush === BrushType.STAMP) {
          // Stamp action
        }
      })
      .draggable({
        onstart: (event) => {
          if (props.brush === BrushType.LINE) {
            // Line action start
          }
        },
        onmove: (event) => {
          if (props.brush === BrushType.LINE) {
            // Line action draw
          }
        },
        onend: (event) => {
          if (props.brush === BrushType.LINE) {
            // Line action end
          }
        },
      });
    }, []);

  return (
    <div>
      <canvas id='bad-canvas' />
    </div>
  );
}

 

You may have already physically recoiled after reading such a monstrosity, but let me outline some the drawbacks of the above approach anyway:

  • Conditional logic: We use 4 if statements to trigger certain brush behaviours. Consider adding one more brush: up to 4 more else ifs may have to be added to accommodate the new behaviour. Consider introducing a new event from InteractJS, such as onHoverMove - a new if...else if... statement has to be constructed. 
  • Testing: Testing the behaviour of individual brushes is a nightmare. For a start, the logic is woven together within a React component. We also have to mock the mouse events in such a way that the callbacks passed to InteractJS will be triggered.
  • Code Reuse: The Live Demo has two very similar brushes, the 'Path Brush' and the 'Capped Path Brush'. It would be nice to be able to share code between them so that if we want to change path behaviour, we only have to change it in one place, not two. Achieving this in the above code is possible, but not pretty.
  • Brush Limitations: It would be nice to be able to give brushes their own state. For example, the 'Dot Brush' in the live demo stores the position of the last dot, so that a new dot is added only if it is far enough away. Storing state for each brush within this component would be possible, but very awkward, especially if we're reusing code between brushes.

A Better Approach

Let's look at it from an object-oriented standpoint. First, the AbstractBrush - a class that encapsulates a brush but cannot be instantiated itself (it has no actual behaviour). It will have default touch event method implementations that can be overridden by subclasses. Next, the PathBrush, StampBrush, DotBrush. These extend the AbstractBrush, and override touch events relevant to them. Finally, the CappedPathBrush. By extending the PathBrush, we get code reuse for free, and can override methods to add functionality as needed.

UML Diagram for brushes

Putting it Into Practice

src/brushes/Brush.ts

This class encapsulates the 'Brush'. It is abstract as it cannot be instantiated - it has no real behaviour. The name field is abstract - this forces subclasses to give it a concrete value. If you use an IDE such as VSCode, you will see an error if subclasses do not define the name field. In this demo the name is the human-readable name on the brush button - in a real application this field may not belong here, I just use it as an example of how an abstract field could be used.

The remaining methods have default implementations that do nothing, each corresponding to an InteractJS event. I made this choice as concrete Brushes, such as the 'Path Brush' may use any combination of these events, and should be free to pick and choose which are relevant without having to provide implementations of all of them.


export abstract class Brush {
  abstract name: string;

  onTap(canvas: CanvasWrapper, pos: Vector): void {}
  onDragStart(canvas, pos: Vector): void {}
  onDragMove(canvas, pos: Vector, delta: Vector): void {}
  onDragEnd(canvas, pos: Vector): void {}
  onHoverMove(canvas, pos: Vector, delta: Vector): void {}
}

 

src/brushes/PathBrush.ts

I'll just give two out of the four brush implementation examples, the first being PathBrush. This brush responds to mouse/touch drag events and draws a line following the dragged path. onDragMove is called with the next position of the dragged path, this position is added to the brush state currentLine, and is drawn onto the screen (in this context it is fine to redraw the path every time). Notice that the third (delta) argument is named _, signifying that it is unused in this method.

onDragEnd is called when the drag motion stops, and resets the currentLine state. currentLine is a protected field (TS docs), which makes it accessible to subclasses, this will be useful later.


export class PathBrush extends Brush {
  public name = "Path Brush";
  protected currentLine: Vector[] = [];

  onDragMove(canvas: CanvasWrapper, pos: Vector, _: Vector) {
    this.currentLine.push(pos);
    canvas.drawPath(this.currentLine, 15, colours.black);
  }

  onDragEnd(canvas: CanvasWrapper, _: Vector) {
    this.currentLine = [];
  }
}

 

src/brushes/CappedPathBrush.ts

Now comes a huge benefit of this project structure: code reuse. I want to create a brush which acts similarly to PathBrush - it draws a line but also draws a circle at each end. I could copy-paste the PathBrush class, but that creates problems. If I ever want to change the implementation of how paths act, I now have to remember to change it in two places. Instead, I extend PathBrush.

This gives us path tracing logic for free, and we can override methods as needed to create additional functionality. onDragEnd is overridden from PathBrush to create a circle at the end of the path. super is used to access the parent, and so super.onDragEnd(canvas, pos) calls the parent's implementation of onDragEnd before we continue with the rest of the method - this is the crux of the code reuse. 

Note that calling the super method is optional, you can replace the parent's implementation entirely if you want by omitting super (this is not the case in constructors, where super() must be the first thing called).

We can override methods from the grandparent (Brush) that were not defined in the parent: onDragStart. I have not included the super call in this case as the Brush implementation doesn't do anything, but you may choose to leave it in to make clear that this is an override. 


export class CappedPathBrush extends PathBrush {
  public name = "Capped Path Brush";

  onDragStart(canvas: CanvasWrapper, pos: Vector) {
    canvas.fillCircle(pos, 20, colours.black);
  }

  onDragEnd(canvas: CanvasWrapper, pos: Vector) {
    super.onDragEnd(canvas, pos);
    canvas.fillCircle(pos, 30, colours.yellow);
  }
}

Testability

Splitting the brushes out into their own classes in this way makes it incredibly easy to test brush logic. We now don't have to deal with React components, don't have to mock mouse events, and the brush logic isn't hidden in an InteractJS callback. We can just instantiate a brush, and call its methods directly.

Polymorphism

Now that we know that all brushes necessarily have the fields and method defined in Brush.ts, we can take advantage or polymorphism. It is no longer necessary to use if or switch to check which concrete brush we have, we can simply call brush.onTap for any brush, even if that brush doesn't provide an implementation of the onTap method. This is demonstrated in the class I call 'Painter':

src/painting/Painter.ts (Helper methods omitted for brevity)

This is where we register the callbacks for InteractJS. Compare this to the 'naïve solution' from the start.

  • We don't care which concrete kind of brush we have, since we know for a fact that every brush will have an onTap method (for example), empty or otherwise.
  • Adding more interaction events is easy, no conditional logic is needed - we just add the new event to the abstract Brush and call it from Painter.

export class Painter {
  private brush: Brush;

  constructor(canvas: CanvasWrapper, initialBrush?: Brush) {
    this.brush = initialBrush || new PathBrush();
      interact(canvas.canvasElement)
        .on("tap", (event: InteractEvent) =>
          this.brush.onTap(canvas, this.eventToPos(event))
        )
        .draggable({
          onstart: (event: InteractEvent) =>
            this.brush.onDragStart(canvas, this.eventToPos(event)),
          onmove: (event: InteractEvent) =>
            this.brush.onDragMove(
              canvas,
              this.eventToPos(event),
              this.eventToDelta(event)
            ),
          onend: (event: InteractEvent) =>
            this.brush.onDragEnd(canvas, this.eventToPos(event)),
        });
  }

...

Disadvantages

  • State: In this toy demo we've taken the currently-selected-brush state out of React. This makes it difficult to update the UI to highlight the current brush. Overcoming this could involve a state manager or perhaps the observer pattern (with React hooks?).
  • Specificity: There may be a situation in which you want to know which kind of brush you have, but you have a reference to a Brush and not a specific concrete brush. This is slightly awkward in Typescript and might indicate you can structure your application in a way that removes this need (switch smells bad), but otherwise can be done in a number of ways including:

Next Steps

Hopefully you will now know when an object-oriented approach could be appropriate in your Typescript application. You can take advantage of Typescript inheritance, polymorphism, and classes much further, for example, by implementing design patterns that benefit from the object-oriented approach. This is out of scope for this article, but here is a useful guide covering many of them, I've personally found singleton and builder very useful in my own projects.