In this tutorial I will use React Hooks to create an html canvas drawing website. I will start from zero using the Create React App starter kit. The resulting app offers basic features like Clear
, Undo
and saves drawings using localStorage.
With this tutorial I'd like to show you how hooks make composition and reuse of stateful logic possible using custom hooks.
This is a crosspost. The article Using React Hooks with Canvas first appeared on my own personal blog.
Basic setup
We'll start by creating a new React app using create-react-app
.
$ npx create-react-app canvas-and-hooks
$ cd canvas-and-hooks/
$ yarn start
Your browser should open http://localhost:3000/
and you should see a spinning React logo. You're now ready to go!
1st hook: useRef
Open the file src/App.js
in your favorite editor and replace the contents with the following code:
import React from 'react'
function App() {
return (
<canvas
width={window.innerWidth}
height={window.innerHeight}
onClick={e => {
alert(e.clientX)
}}
/>
)
}
export default App
Clicking somewhere in the open browser window should now display an alert popup, telling you the x coordinate of the mouse click.
Great, it works!
Now let's actually draw something. For that we need a ref
to the canvas element and our first hook useRef
is going help us with that.
import React from 'react'
function App() {
const canvasRef = React.useRef(null)
return (
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={e => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
// implement draw on ctx here
}}
/>
)
}
export default App
Normally in React you don't need a ref
to update something, but the canvas is not like other DOM elements. Most DOM elements have a property like value
that you can update directly. The canvas works with a context (ctx
in our app) that allows you to draw things. For that we have to use a ref
, which is a reference to the actual canvas DOM element.
Now that we have the canvas context it's time to draw something. For that I'm going to copy paste the code that draws an SVG hook. It's got nothing to do with hooks, so don't worry if you don't fully understand it.
import React from 'react'
const HOOK_SVG =
'm129.03125 63.3125c0-34.914062-28.941406-63.3125-64.519531-63.3125-35.574219 0-64.511719 28.398438-64.511719 63.3125 0 29.488281 20.671875 54.246094 48.511719 61.261719v162.898437c0 53.222656 44.222656 96.527344 98.585937 96.527344h10.316406c54.363282 0 98.585938-43.304688 98.585938-96.527344v-95.640625c0-7.070312-4.640625-13.304687-11.414062-15.328125-6.769532-2.015625-14.082032.625-17.960938 6.535156l-42.328125 64.425782c-4.847656 7.390625-2.800781 17.3125 4.582031 22.167968 7.386719 4.832032 17.304688 2.792969 22.160156-4.585937l12.960938-19.71875v42.144531c0 35.582032-29.863281 64.527344-66.585938 64.527344h-10.316406c-36.714844 0-66.585937-28.945312-66.585937-64.527344v-162.898437c27.847656-7.015625 48.519531-31.773438 48.519531-61.261719zm-97.03125 0c0-17.265625 14.585938-31.3125 32.511719-31.3125 17.929687 0 32.511719 14.046875 32.511719 31.3125 0 17.261719-14.582032 31.3125-32.511719 31.3125-17.925781 0-32.511719-14.050781-32.511719-31.3125zm0 0'
const HOOK_PATH = new Path2D(HOOK_SVG)
const SCALE = 0.3
const OFFSET = 80
function draw(ctx, location) {
ctx.fillStyle = 'deepskyblue'
ctx.shadowColor = 'dodgerblue'
ctx.shadowBlur = 20
ctx.save()
ctx.scale(SCALE, SCALE)
ctx.translate(location.x / SCALE - OFFSET, location.y / SCALE - OFFSET)
ctx.fill(HOOK_PATH)
ctx.restore()
}
function App() {
const canvasRef = React.useRef(null)
return (
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={e => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
draw(ctx, { x: e.clientX, y: e.clientY })
}}
/>
)
}
export default App
All this does is draw an SVG shape (a fishing hook!) on position x
and y
. As it's not relevant for this tutorial I will omit it from now on.
Try it out, see if it works!
2nd hook: useState
The next features we'd like to add are the Clean
and Undo
buttons. For that we need to keep track of the user interactions with the useState
hook.
import React from 'react'
// ...
// canvas draw function
// ...
function App() {
const [locations, setLocations] = React.useState([])
const canvasRef = React.useRef(null)
return (
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={e => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
draw(ctx, newLocation)
}}
/>
)
}
export default App
There! We've added state to our app. You can verify this by adding a console.log(locations)
just above the return
. In the console you should see a growing array of the user clicks.
3rd hook: useEffect
Currently we're not doing anything with that state. We're drawing the hooks just like we did before. Let's see how we can fix this with the useEffect
hook.
import React from 'react'
// ...
// canvas draw function
// ...
function App() {
const [locations, setLocations] = React.useState([])
const canvasRef = React.useRef(null)
React.useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
locations.forEach(location => draw(ctx, location))
})
return (
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={e => {
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
}}
/>
)
}
export default App
There's a lot going on here so let's break it down. We've moved the drawing function from the onClick handler to the useEffect
callback. This is important, because drawing on the canvas is a side effect determined by the app state. Later we'll add persistency using localStorage, which will also be a side effect of state updates.
I've also made a few changes to the actual drawing on the canvas itself. In the current implementation every render first clears the canvas and then draws all the locations. We could be smarter than that, but to keep it simple I'll leave it to reader to further optimize this.
We've done all the hard work, adding the new feature should be easy now. Let's create the Clear
button.
import React from 'react'
// ...
// canvas draw function
// ...
function App() {
const [locations, setLocations] = React.useState([])
const canvasRef = React.useRef(null)
React.useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
locations.forEach(location => draw(ctx, location))
})
function handleCanvasClick(e) {
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
}
function handleClear() {
setLocations([])
}
return (
<>
<button onClick={handleClear}>Clear</button>
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={handleCanvasClick}
/>
</>
)
}
export default App
The Clear feature is just a simple state update: we clearing the state by setting it to an empty array []
. That was easy right?
I've also taken the opportunity to clean up a bit, by moving the canvas onClick
handler to a separate function.
Let's do another feature: the Undo
button. Same principle, even though this state update is a bit more tricky.
import React from 'react'
// ...
// canvas draw function
// ...
function App() {
const [locations, setLocations] = React.useState([])
const canvasRef = React.useRef(null)
React.useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
locations.forEach(location => draw(ctx, location))
})
function handleCanvasClick(e) {
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
}
function handleClear() {
setLocations([])
}
function handleUndo() {
setLocations(locations.slice(0, -1))
}
return (
<>
<button onClick={handleClear}>Clear</button>
<button onClick={handleUndo}>Undo</button>
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={handleCanvasClick}
/>
</>
)
}
export default App
Since any state update in React has to be immutible, we can't use something like locations.pop()
to remove the most recent item from the array. We have to do it without changing the original locations
array. The way to do this is with slice
, i.e. by slicing off all the elements up until the last one. You can do that with locations.slice(0, locations.length - 1)
, but slice
is smart enough to interpret -1
as the last item in the array.
Before we continue, let's clean up the html and add some css. Add the following div
around the buttons:
import React from 'react'
import './App.css'
// ...
// canvas draw function
// ...
function App() {
// ...
return (
<>
<div className="controls">
<button onClick={handleClear}>Clear</button>
<button onClick={handleUndo}>Undo</button>
</div>
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={handleCanvasClick}
/>
</>
)
}
export default App
And replace the css in App.css
with the following:
*,
*:before,
*:after {
box-sizing: border-box;
}
body {
background-color: black;
}
.controls {
position: absolute;
top: 0;
left: 0;
}
button {
height: 3em;
width: 6em;
margin: 1em;
font-weight: bold;
font-size: 0.5em;
text-transform: uppercase;
cursor: pointer;
color: white;
border: 1px solid white;
background-color: black;
}
button:hover {
color: black;
background-color: #00baff;
}
button:focus {
border: 1px solid #00baff;
}
button:active {
background-color: #1f1f1f;
color: white;
}
Looking good, let's get started on the next feature: persistence!
Adding localStorage
As mentioned before, we also want our drawings to be saved to localStorage
. As this is another side effect, we'll add another useEffect
.
import React from 'react'
import './App.css'
// ...draw function
function App() {
const [locations, setLocations] = React.useState(
JSON.parse(localStorage.getItem('draw-app')) || []
)
const canvasRef = React.useRef(null)
React.useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
locations.forEach(location => draw(ctx, location))
})
React.useEffect(() => {
localStorage.setItem('draw-app', JSON.stringify(locations))
})
function handleCanvasClick(e) {
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
}
function handleClear() {
setLocations([])
}
function handleUndo() {
setLocations(locations.slice(0, -1))
}
return (
<>
<div className="controls">
<button onClick={handleClear}>Clear</button>
<button onClick={handleUndo}>Undo</button>
</div>
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={handleCanvasClick}
/>
</>
)
}
export default App
We've now completed all the features we set out to build, but we're not done yet. One of the coolest things about hooks is that you can use existing hooks to compose new custom hooks. Let me demonstrate this by creating a custom usePersistentState
hook.
1st custom hook: usePersistentState
import React from 'react'
import './App.css'
// ...draw function
// our first custom hook!
function usePersistentState(init) {
const [value, setValue] = React.useState(
JSON.parse(localStorage.getItem('draw-app')) || init
)
React.useEffect(() => {
localStorage.setItem('draw-app', JSON.stringify(value))
})
return [value, setValue]
}
function App() {
const [locations, setLocations] = usePersistentState([])
const canvasRef = React.useRef(null)
React.useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
locations.forEach(location => draw(ctx, location))
})
function handleCanvasClick(e) {
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
}
function handleClear() {
setLocations([])
}
function handleUndo() {
setLocations(locations.slice(0, -1))
}
return (
// ...
)
}
export default App
There! We've created our first custom hook and all the logic that relates to saving and getting the state from localStorage is extracted from the App component. And we did this in a way that the hook usePersistentState
can be reused by other components. There's nothing in there that's specific for this component.
Let's repeat this trick for the logic that relates to the canvas.
2nd custom hook: usePersistentCanvas
import React from 'react'
import './App.css'
// ...draw function
// our first custom hook
function usePersistentState(init) {
const [value, setValue] = React.useState(
JSON.parse(localStorage.getItem('draw-app')) || init
)
React.useEffect(() => {
localStorage.setItem('draw-app', JSON.stringify(value))
})
return [value, setValue]
}
// our second custom hook: a composition of the first custom hook and React's useEffect + useRef
function usePersistentCanvas() {
const [locations, setLocations] = usePersistentState([])
const canvasRef = React.useRef(null)
React.useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
locations.forEach(location => draw(ctx, location))
})
return [locations, setLocations, canvasRef]
}
function App() {
const [locations, setLocations, canvasRef] = usePersistentCanvas()
function handleCanvasClick(e) {
const newLocation = { x: e.clientX, y: e.clientY }
setLocations([...locations, newLocation])
}
function handleClear() {
setLocations([])
}
function handleUndo() {
setLocations(locations.slice(0, -1))
}
return (
<>
<div className="controls">
<button onClick={handleClear}>Clear</button>
<button onClick={handleUndo}>Undo</button>
</div>
<canvas
ref={canvasRef}
width={window.innerWidth}
height={window.innerHeight}
onClick={handleCanvasClick}
/>
</>
)
}
export default App
As you can see our App component has become quite small. All the logic that is related to storing the state in localStorage and drawing on the canvas is extracted to custom hooks. You could clean up this file even further by moving the hooks into a hooks file. That way other components could reuse this logic, for instance to compose even better hooks.
Conclusions
What makes hooks so special if you compare them to the lifecycle methods (like componentDidMount
, componentDidUpdate
)? Looking at the examples above:
- hooks allow you to reuse lifecycle logic in different components
- with hooks you can use composition to create richer custom hooks, just like you can use composition to create richer UI components
- hooks are shorter and cleaner - no more bloated, and sometimes confusing, lifecycle methods
It's still too early to tell whether hooks are really going to solve all these problems - and what new bad practices might arise from them - but looking at the above I'm pretty excited and optimistic for React's future!
Let me know what you think! You can reach me on Twitter using @vnglst.
Top comments (0)