← Back toMaster of React ref

Build your own drawing board

Written byPhuoc Nguyen
Created
18 Oct, 2023
Tags
drawing board component, useRef() hook
In our previous post, we learned about the `useRef()` hook and how we can use it to add smooth animation to a slider. Now, we'll show you another real-life example of how to take advantage of the `useRef` hook. We'll build a drawing board that allows users to draw by clicking and moving the mouse.
This drawing board can be used in many cases, such as creating an e-signature pad. With the rise of remote work and online transactions, e-signatures have become increasingly important. Our drawing board can capture the user's signature as an image, making it easy to add to any document or form. This is especially useful for businesses that need to process contracts or legal documents remotely.
Another real-life example where our drawing board component can be used is in an online whiteboard application. With the growing number of remote work and virtual meetings, having a digital whiteboard that allows users to draw and collaborate can be very useful. Our drawing board component can be integrated into such an application, allowing users to sketch out their ideas and share them with others in real-time. It can also be used as a tool for educational purposes, where teachers or students can use it to illustrate concepts or solve problems collaboratively.

Understanding the data structure

When it comes to building a drawing board component, we have two main options: using canvas or SVG elements.
Canvas is a powerful element that lets us create shapes and images dynamically. It's perfect for complex applications that need real-time updates. With canvas, we can build a drawing board that responds smoothly to user input and offers various drawing tools like pencils, brushes, and erasers.
SVG, on the other hand, is a vector-based image format that uses SVG tags to define graphics. It's great for creating scalable graphics and animations that can be easily manipulated with CSS or JavaScript. With SVG, we can build a lightweight and accessible drawing board that supports advanced features like gradients, filters, and masks.
Each option has its own strengths and weaknesses, so we need to choose the one that fits our application's specific needs. In the following sections, we'll explore how to build a drawing board component using SVG.
To create this drawing board with SVG, we first need a data structure to hold the lines that the user draws. We'll define a `Line` type that has an `id` and an array of `Point`s. Each `Point` has an `x` and `y` coordinate that we'll use to draw the line.
ts
type Point = {
x: number;
y: number;
};
type Line = {
id: string;
points: Point[];
};
The `id` property is randomly generated to tell lines apart. These lines mimic what users draw on the board.
To create a single line representing what users draw on the board, we use the lines generated as users click and move their mouse. We then map each line to a `polyline` element in SVG. The `points` attribute of the `polyline` element takes a string of x,y coordinates that define the path of the line. We create this string by mapping each point in the line's array of points to a string representation of its coordinates using template literals and joining them with spaces.
By mapping all lines to polylines, we can recreate what users drew on the board.
Here's some sample code to give you an idea of how to recreate the lines. The `id` properties differentiate between lines and are used as the `key` attribute.
tsx
<svg>
{
lines.map(({ id, points }) => (
<polyline
key={id}
points={points.map((point) => `${point.x},${point.y}`).join(" ")}
/>
))
}
</svg>;
Using this data structure makes it a breeze to store and retrieve lines in a database.
Storing the lines in a database has many benefits. Firstly, it allows us to reload the drawing board with the previous content if needed. Additionally, storing the lines in a data structure makes it easy to modify and manipulate the drawing board's content. We can add new lines or delete existing ones by manipulating the data structure before rendering it on the board.
For instance, we can save an array of `Line` objects that represent all the lines drawn on a specific drawing board in a database. Then, when a user opens the drawing board again, we can fetch this array from the database and render each line as a polyline on the SVG element.
By doing this, we preserve any previous work done on the drawing board and make it simple for users to pick up where they left off.

Building the drawing board component

Now that you know the data structure we'll use to create our drawing board component, let's get to building it. We'll start by using the `useRef()` hook to create a reference to the SVG element.
tsx
const DrawingBoard = () => {
const svgRef = React.useRef();

// Render
return <svg className="board" ref={svgRef}></svg>;
};
The SVG element comes with the `board` CSS class. It's nothing special, really, except for the cursor we use, which lets users know they can draw with their mouse.
css
.board {
cursor: crosshair;
}
To store the data in the structure described earlier, we use two internal states: `id` and `lines`.
The `id` state represents the ID of the latest line, while the `lines` state represents all lines. The `id` state is initially set to an empty string, and the `lines` state is set to an empty array since no lines have been drawn yet.
tsx
const [id, setId] = React.useState("");
const [lines, setLines] = React.useState<Line[]>([]);
Furthermore, we utilize a separate state to monitor mouse movement by users. This state is initially set to `false`.
tsx
const [isDrawing, setIsDrawing] = React.useState(false);
It's finally time to see the good stuff! We need to handle the `mousedown` event to detect when the user clicks on the drawing board. Here's how we do it:
ts
const handleMouseDown = (e) => {
const id = generateId();
const svgRect = svgRef.current.getBoundingClientRect();
const startingPoint = {
x: e.clientX - svgRect.x,
y: e.clientY - svgRect.y,
};
setIsDrawing(true);
setId(id);
setLines((lines) =>
lines.concat({
id,
points: [startingPoint],
})
);
};

// Render
return (
<svg ref={svgRef} onMouseDown={handleMouseDown}></svg>
);
In the handler, we generate a new ID for the line that users will draw and calculate its starting point by subtracting the SVG element's coordinates from the client's current position. We use the `svgRef.current.getBoundingClientRect()` method to find the SVG element's position relative to the viewport and create the `startingPoint` object using these coordinates.
Then, we set `isDrawing` to true to indicate that users are drawing on the board. We update both the `id` and `lines` states with a newly generated ID and an array containing a single point at that moment. This creates a new line with an ID and one point representing where users started drawing.
There are several ways to generate a random ID, but for this example, we'll be using the function mentioned in this post.
As users move the mouse around, we need to store the lines they draw. To do this, we handle the `mousemove` event.
Here's the code snippet for the `mousemove` event handler:
ts
const handleMouseMove = (e) => {
if (!isDrawing) {
return;
}
const svgRect = svgRef.current.getBoundingClientRect();

setLines((lines) =>
lines.map((line) =>
line.id === id
? {
...line,
points: line.points.concat({
x: e.clientX - svgRect.x,
y: e.clientY - svgRect.y,
}),
}
: line
)
);
};
The `handleMouseMove` function tracks the user's mouse movement after they have clicked down on the board to start drawing. First, the function checks whether the user is currently drawing by checking the `isDrawing` state. If they are not, then the function does nothing.
If the user is currently drawing, we retrieve the position of the SVG element relative to the viewport using `svgRef.current.getBoundingClientRect()`. This information helps us calculate where the user's mouse cursor currently is and add these new coordinates to our data structure for lines.
We use the `setLines` method to update our state. We map over all existing lines and check which line is currently being drawn by comparing its `id` with our current `id`. If it matches, we append a new point object containing x and y coordinates calculated from subtracting the SVG's top-left corner's coordinates from the `clientX` and `clientY` coordinates of `MouseEvent` e.
This creates a new point at each move event that represents where the user's mouse cursor currently is. When the user finishes their drawing and lifts their mouse up, we can store all points as one line in our data structure for lines.
Finally, when the user releases the mouse to stop drawing, we update the corresponding state by setting the `isDrawing` state to `false`.
ts
const handMouseUp = () => {
setIsDrawing(false);
};
When users move their mouse over the entire SVG element, the same thing should occur.
ts
const handMouseLeave = () => {
setIsDrawing(false);
};
Let's check out how we declare the event handlers:
tsx
<svg
ref={svgRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handMouseUp}
onMouseLeave={handMouseLeave}
>
...
</svg>
Lastly, we render each line as a polyline. We achieve this by mapping over the corresponding points in our state array and rendering them as attributes of each polyline tag in JSX.
tsx
<svg>
{
lines.map(({ id, points }) => (
<polyline
key={id}
points={points.map((point) => `${point.x},${point.y}`).join(" ")}
/>
))
}
</svg>
Check out the demo below:

Making the drawing board mobile-friendly

In order for our drawing board to work on mobile devices, we need to add touch event handling alongside mouse event handling. This will allow users to draw on the board using their fingers instead of a mouse.
To handle touch events, we can use the `onTouchStart`, `onTouchMove`, and `onTouchEnd` events. These events are similar to their mouse counterparts, but they provide information about touches instead of clicks.
Let's start by adding an event listener for touch start events.
tsx
const handleTouchStart = (e) => {
e.preventDefault();
const id = generateId();
const svgRect = svgRef.current.getBoundingClientRect();
const startingPoint = {
x: e.touches[0].clientX - svgRect.x,
y: e.touches[0].clientY - svgRect.y,
};
setIsDrawing(true);
setId(id);
setLines((lines) =>
lines.concat({
id,
points: [startingPoint],
})
);
};

// Render
return (
<svg
ref={svgRef}
style={{
touchAction: "none",
}}
onTouchStart={handleTouchStart}
></svg>
);
In this code snippet, we added an event listener for touch start events that calls the same `handleMouseDown` function as our mouse down event handler.
To disable the browser's handling of touch events (like scrolling and zooming), we set the `touchAction` style property to `none`.
Now, we're ready to add an event listener for touch move events.
tsx
const handleTouchMove = (e) => {
if (!isDrawing) {
return;
}
const svgRect = svgRef.current.getBoundingClientRect();

setLines((lines) =>
lines.map((line) =>
line.id === id
? {
...line,
points: line.points.concat({
x: e.touches[0].clientX - svgRect.x,
y: e.touches[0].clientY - svgRect.y,
}),
}
: line
)
);
};

// Render
return (
<svg
ref={svgRef}
onTouchMove={handleTouchMove}
></svg>
);
We've added an event listener for touch move events that calls the same `handleMouseMove` function as our mouse move event handler. We also check whether users are currently drawing by using the `isDrawing` state. If they are not, then we do nothing.
To wrap it up, we can add an event listener for touch end events.
tsx
const handleTouchEnd = () => {
setIsDrawing(false);
};

// Render
return (
<svg
ref={svgRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
></svg>
);
In the code snippet, we added an event listener for when a touch ends, which triggers the same `setIsDrawing(false)` function that our mouse up event handler uses.
By adding these three touch event listeners to our SVG element, we can now handle drawing on both mobile and desktop devices.

Demo

Take a look at the final demo below. You might notice some duplicated code for handling mouse and touch events. However, if you check out the code in the final demo, you'll see that the common parts have been extracted to separate functions.
It's worth noting that this example demonstrates the power of the `useRef()` hook for building a drawing board. There's still plenty of room for improvement, such as adding zoom in/out functionality or undo/redo functionality. These tasks are left for you to tackle.

See also

If you found this post helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks 😍. Your support would mean a lot to me!

Questions? 🙋

Do you have any questions about front-end development? If so, feel free to create a new issue on GitHub using the button below. I'm happy to help with any topic you'd like to learn more about, even beyond what's covered in this post.
While I have a long list of upcoming topics, I'm always eager to prioritize your questions and ideas for future content. Let's learn and grow together! Sharing knowledge is the best way to elevate ourselves 🥷.
Ask me questions

Recent posts ⚡

Newsletter 🔔

If you're into front-end technologies and you want to see more of the content I'm creating, then you might want to consider subscribing to my newsletter.
By subscribing, you'll be the first to know about new articles, products, and exclusive promotions.
Don't worry, I won't spam you. And if you ever change your mind, you can unsubscribe at any time.
Phước Nguyễn