The <canvas/>
HTML element can be used to draw graphics with a finer control than the usual DOM or SVG. But with React, trying to draw on a canvas is not intuitive as their interfaces are quite different. With React, each component owns their node, as opposed to canvas where there is only one shared node that we can use for drawing. Let's see how we can make a canvas visualization with React components !
π§βπ«Canvas 101
The canvas
element is like a sheet of paper. To draw in real life you would take a pen, move your hand to a first position, and draw a line by moving your hand to another position. The browser API to draw on a canvas is actually very similar. We first need to make a blueprint of the shape we want to draw β like using a real pencil β that can later be colored in.
// moving our hand to the starting position
canvasContext.moveTo(x1, y1);
// drawing a blueprint line to the finishing position
canvasContext.lineTo(x2, y2);
// taking a purple pen and coloring the line
canvasContext.strokeStyle = "purple";
canvasContext.stroke();
Having imperative code like this in a component-oriented codebase can be tricky! We would need to create a component that renders a <canvas/>
on the page and then call the moveTo
and lineTo
methods on it to draw a line. In practice, it's a bit more complicated to bridge those two. We need to use a React reference to access the canvas DOM node, and to retrieve a 2D context from it; we are then able to call our drawing methods. The code would look like this:
const Canvas = () => {
// we use a ref to access the canvas' DOM node
const canvasRef = React.useRef(null);
React.useEffect(() => {
const context = canvasRef.current.getContext("2d");
// ...drawing using the context
}, [canvasRef]);
return <canvas ref={canvasRef} />;
};
Edit on CodeSandbox
But if we want to draw something a bit more complex, the Canvas
component can become quite large. Usually, big components are split into several child components. Yet here this is not possible as there is only one canvas
node.
π¨Hexagons
To show how to make child components with canvas, let's draw something more fancy β¨
A single hexagon is defined with the folowing data:
- A position on the screen β two
x
andy
number values. - A
radius
to represent its size. - A
rotation
so that all hexagons don't look aligned. - A
color
.
We need a function that is able to generate some random hexagons. The randomisation code is not relevant here; let's just assume we have a way to get an array of hexagons. As for how to draw the shape of an hexagon β we need to draw a line between all its corners and then fill it with a color:
// This article explains all the math behind hexagons
// https://www.redblobgames.com/grids/hexagons/
const corners = getHexagonCorners(x, y, radius, rotation);
context.beginPath();
corners.forEach((corner, index) => {
if (index === 0) {
context.moveTo(corner.x, corner.y);
} else {
context.lineTo(corner.x, corner.y);
}
});
context.fillStyle = color;
context.fill();
How could we extract this logic into its own Hexagon
component? The component would need the canvas's context in order to draw anything. This could be passed via a prop to all child components, but this approach can become tedious when children are deeply nested. Another way of doing this is by using a React context to share "global" values between components.
π¦A context in a context
At this point the naming gets a bit tricky as we are trying to share a canvas' context via a React context. Once we create a React context, we need to use the context's Provider
to share a value. In the case of our Canvas
component it would look like this:
// we create a React context with a _null_ default value
const SharingContext = React.createContext(null);
const Canvas = (props) => {
const canvasRef = React.useRef(null);
const [renderingContext, setRenderingContext] =
React.useState(null);
// the canvas rendering context is not immediately avalaible
// the canvas node first needs to be added to the DOM by react
React.useEffect(() => {
const context2d = canvasRef.current.getContext("2d");
setRenderingContext(context2d);
}, []);
return (
<SharingContext.Provider value={renderingContext}>
<canvas ref={canvasRef} />
{/* hexagons are passed through the `children` prop */}
{props.children}
</SharingContext.Provider>
);
};
Edit on CodeSandbox
The Hexagon
component needs to consume this React context to read its value β here with the useContext
hook.
const Hexagon = (props) => {
// we get the rendering context by comsuming the React context
const renderingContext = React.useContext(SharingContext);
if (renderingContext !== null) {
// hexagon drawing logic
}
};
Edit on CodeSandbox
Now that both our Canvas
and Hexagon
components are ready we are able to display randomly-generated hexagons:
const App = () => (
<Canvas>
{getRandomHexagons().map((hexagon) => (
<Hexagon {...hexagon} />
))}
</Canvas>
);
Edit on CodeSandbox
The last thing we need is to animate the hexagons so that they rotate.
π¬Animations
As we saw, the canvas is like a sheet of paper β once it's been drawn on, it can't be changed! However, a canvas can be cleared in order that something new can be draw on it. In that respect animating a canvas is somewhat like old-fashioned cartoon animation - we draw, clean, draw, clean and repeat until we achieve the desired effect. To make a shape move, you need to split the movement into small steps, draw them one by one, while clearing the canvas in-between. Those steps are called frames. Browsers come with an API requestAnimationFrame
so that you can draw in each frame.
πΌCreating a frame loop
First things first - the canvas should be cleared at the beginning of each frame. The easiest way to do this is to have an internal state counting the frames. This way, the component re-renders at each frame:
const [frameCount, setFrameCount] = React.useState(0);
// this effect increments frameCount by one at the next frame
// as it's called every time frameCount changes
// this makes the Canvas component re-render at every frame
React.useEffect(() => {
const frameId = requestAnimationFrame(() => {
setFrameCount(frameCount + 1);
});
return () => {
cancelAnimationFrame(frameId);
};
}, [frameCount, setFrameCount]);
// here's the clearing at every render β at every frame.
if (context !== null) {
context.clearRect(0, 0, actualWidth, actualHeight);
}
Edit on CodeSandbox
But... the canvas is now white! This is because the hexagons are only rendered once - when <RandomHexagons/>
is first rendered. But, as the canvas is cleared on each frame, the hexagons are erased once the next render occurs. Child components must be forced to re-render and draw in the canvas on every frame. One solution is to share the frameCount
from the canvas with the Hexagon
component. This is achieved via a React context, like we did with SharingContext
:
const FrameContext = React.createContext(0);
const Canvas = (props) => {
// [...]
return (
<SharingContext.Provider value={renderingContext}>
<FrameContext.Provider value={frameCount}>
<canvas />
{props.children}
</FrameContext.Provider>
</SharingContext.Provider>
);
};
const Hexagon = (props) => {
const renderingContext = React.useContext(SharingContext);
const frameCount = React.useContext(FrameContext);
// drawing logic
};
And now our hexagons are back on the screen! π While this method works, the FrameContext
has to be added to every child component. Two options are available to make sure that this context is used everywhere that SharingContext
is:
-
We can regroup them into a single context that shares both the
renderingContext
and theframeCount
. But theframeCount
variable is not used in the child components, so it does not make sense to share its value. -
Or, we can create a
useCanvas
hook to hide this complexity away! Even when consuming both React contexts, the hook can only return the canvas rendering context to the child components:
export const useCanvas = () => {
React.useContext(FrameContext);
const renderingContext = React.useContext(CanvasContext);
return renderingContext;
};
The Hexagon
component logic now needs a small update to use this new hook:
const Hexagon = (props) => {
const renderingContext = useCanvas();
// drawing logic
};
πMaking the hexagons move
If we want the hexagons to rotate, each hexagon should change its rotation angle at every frame - by incrementing it by 1, for example. To do this, the hexagons needs to remember the rotation from the previous render. We will use a React ref to achieve this:
const animatedRotation = React.useRef(props.rotation);
animatedRotation.current = animatedRotation.current + 1;
As the Hexagon
component re-renders at every frame, this code makes its rotation change at every frame: they rotate! π
Like we did with useCanvas
, we can also improve the readability of this code by hiding the implementation details β here, using a React ref β in a hook:
const useAnimation = (initialValue, valueUpdater) => {
const animatedValue = React.useRef(initialValue);
animatedValue.current = valueUpdater(
animatedValue.current
);
return animatedValue.current;
};
Edit on CodeSandbox
The Hexagon
code now looks a bit better:
const Hexagon = (props) => {
// [...]
const animatedRotation = useAnimation(
props.rotation,
(angle) => angle + 1
);
// drawing logic
};
Edit on CodeSandbox
π Congratulations π We now have animated canvas-based React components! We even created two custom hooks along the way to make our code nicer.
πGoing further
-
At some point it can become quite CPU heavy to try to animate a lot of shapes in a canvas. At this point, there's another more performant way to draw things: WebGL. This is a huge subject on its own! If you want to write WebGL-based components, I would recommend using a library like react-three-fiber β spoiler alert, they made their own React reconciler
-
Our
useAnimation
hook does not help if we want to animate stuff other than infinitely changing numbers. To create more complex things, react-spring is the library to go to. It needs a bit of wiring to make it work with our own frame loop β here's how to get it working withreact-three-fiber
for example.