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.
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.
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.
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.
Try out the demo if you haven't already.
In the next sections we'll take a look through some of the code and see how polymorphism helped here.
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:
if
statements to trigger certain brush behaviours. Consider adding one more brush: up to 4 more else if
s 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. 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.
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 Brush
es, 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 {}
}
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);
}
}
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.
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.
onTap
method (for example), empty or otherwise.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)),
});
}
...
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:
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.