We have talked before in Text Recorder: React States, Event Handling and Conditional Rendering about states and how to set them and handle their changes. That was while using Class components, but of course we don't have to use Class components to get all the perks, do we?
Let's find out how we can do the same for Function components!
Hooks
Hooks allow us to use states and lifecycle methods within a Function component. They were not always there, they have been recently introduced in React 16.8
They are Javascript functions, but they can NOT be called inside loops, conditions, or nested functions. They always have to be called at the top level of your React function.
We are going to discuss 2 main Hooks:
- useState
- useEffect
UseState
To set a state in a Class component, we used this.state
in the constructor or this.setState()
anywhere else. Our code would look something like this:
this.setState({
dummyState: "dum dum"
});
To use Hooks to rewrite the above code, we are going to need the help of useState. It accepts a parameter that can be used to set the initial value of the state and returns an array with its first element as the current value of this state and its second element as a function to be used later to set the value of the state.
const [dummyState, setDummyState]= useState("dum dum");
We can name them anything we want, of course, but the convention goes as above. Also, it is common to use the array destructuring method to easily access the returned values.
To update the state's value later, we simply call the returned function with the updated value.
setDummyState("dum dum dum");
useEffect
We previously learned about componentDidMount, componentDidUpdate, and componentWillUnmount in Woof Vs. Meow: Data Fetching and React Component Lifecycle. Our useEffect Hook can act as an equivalent to all of them combined. Isn't that some cool Hook?
useEffect accepts a function as a parameter and also an optional array. Let's translate the following code to Hooks to get a better understanding!
Both
componentDidMount(){
functionThatFetchesSomeData();
}
And
componentDidUpdate(){
functionThatFetchesSomeData();
}
Can be translated to the same thing by the useEffect Hook
useEffect(()=>{
functionThatFetchesSomeData();
});
As mentioned before, the useEffect Hook acts as componentDidUpdate. It re-runs whenever any update occurs. Sometimes we want to filter when to run our useEffect and that's why the second array parameter exists. By passing a certain state to this array, we would be telling our Hook to compare its current value with its previous value and only if they were different from each other then our code would run.
useEffect(()=>{
functionThatFetchesSomeData();
},[userId]);
We can have multiple useEffect Hooks and each can have its own filter and its own code.
If we only want to fetch data when the component mounts and we don't want to rerun our code on update, we can trick our Hook and provide it with an empty array as the second argument and by that it would never detect any changes in the array and will only run once.
Our final method to discuss is componentWillUnmount which is known to be used as a clean up method. To let our Hook know what to clean up all we have to do is return a function with our instructions.
useEffect(()=>{
functionThatOpensAnImaginaryConnection();
return ()=>{
functionThatClosesAnImaginaryConnection();
}
});
That's enough to get us started on building something! I am already Hooked!
Build What?
Do you know how sometimes when you are explaining something, you just feel like backing your theory up with a disfigured hand-drawn diagram? Or when you're trying to solve a problem and you need to scribble some notes to understand it better?
Today, we are going to build our own whiteboard to draw upon all the disfigured shapes and scribbles we want!
Experiment a little HERE
What's The Plan?
We want to have a huge white space to draw on, so there goes our first component, let's call it Board! We also, want to have a couple of controls to change the color and erase our content, so that will add to our application three more components; one for Controls, the other for Color and another for Eraser.
Let's roll!
Board
By now, we should be able to install create-react-app and create our folder structure with our eyes blindfolded, so I am going to sit this one out.
The first thing we need in our Board component is a canvas element. Usually, to add 2d context to our canvas and make it drawable, we select it using its id, but in React no selections with id-s or classes take place. Instead, to do that we are going to use refs.
We have talked previously, about handling refs in Class components and they're not so different in Function components. Let's see how they look like!
import React from "react";
import "./Board.css";
function Board() {
const canvasRef = React.useRef(null);
return (
<div className="board">
<canvas ref={canvasRef} />
</div>
);
}
export default Board;
Let's add our Board to our App to view the changes as we are used to!
import React from "react";
import "./App.css";
import Board from "./components/Board/Board";
function App() {
return (
<div className="app">
<Board />
</div>
);
}
export default App;
Now we are going to start using our Hooks. Let's import useState and start by adding our context!
import React,{useState} from "react";
import "./Board.css";
function Board() {
const canvasRef = React.useRef(null);
const [ctx, setCtx] = useState({});
return (
<div className="board">
<canvas ref={canvasRef} />
</div>
);
}
export default Board;
We are going to need to set our context for the canvas the first thing. In Class components we would have used componentDidMount, which as we agreed in our case would be replaced by useEffect Hook. So let's import it and set our context!
import React, { useState, useEffect } from "react";
import "./Board.css";
function Board() {
const canvasRef = React.useRef(null);
const [ctx, setCtx] = useState({});
useEffect(() => {
let canv = canvasRef.current;
let canvCtx = canv.getContext("2d");
canvCtx.lineJoin = "round";
canvCtx.lineCap = "round";
canvCtx.lineWidth = 5;
setCtx(canvCtx);
}, [ctx]);
return (
<div className="board">
<canvas ref={canvasRef} />
</div>
);
}
export default Board;
I gave the context some basic settings and added ctx
as my second parameter to useEffect to trigger it only when ctx
changes and avoid entering an infinite loop of setting its value.
Great! Now we need to take care of the events we will use.
We would need to handle 3 major events:
- onMouseDown when we click the mouse to start drawing
- onMouseMove when we move the mouse while drawing
- onMouseUp when we leave the mouse to stop drawing
Let's add these events to our canvas element
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
/>
handleMouseDown
For this event we are going to need a flag to keep track of whether the drawing process is started or not and give it an initial state of false
const [drawing, setDrawing] = useState(false);
And in our function we are just going to set it to true
function handleMouseDown() {
setDrawing(true);
}
handleMouseUp
In this function we are going to do the exact opposite to what we did in the handleMouseDown function
function handleMouseUp() {
setDrawing(false);
}
handleMouseMove
This is our main function that handles the drawing. We need to move to the last mouse position we detected and draw a line from that point all the way to our current mouse position.
So, first thing is we are going to record the previous position with a start value of (0,0)
const [position, setPosition] = useState({ x: 0, y: 0 });
We also need to record our canvas offset. In our case the canvas would be located at the top left corner of the window, but maybe we would like to add another element or some CSS that will shift its position, later.
const [canvasOffset, setCanvasOffset] = useState({ x: 0, y: 0 });
To guarantee that our mouse position gives us the expected results, we will record the canvas left and top offset, when setting our context.
useEffect(() => {
let canv = canvasRef.current;
let canvCtx = canv.getContext("2d");
canvCtx.lineJoin = "round";
canvCtx.lineCap = "round";
canvCtx.lineWidth = 5;
setCtx(canvCtx);
let offset = canv.getBoundingClientRect();
setCanvasOffset({ x: parseInt(offset.left), y: parseInt(offset.top) });
}, [ctx]);
After that, we will easily be able to detect the position by subtracting that offset from our mouse position. Now, we have our previous and current position. Before we begin our path, we just need to check our drawing flag to make sure the process is ongoing and after we're done we will set our position for the next stroke.
function handleMouseMove(e) {
let mousex = e.clientX - canvasOffset.x;
let mousey = e.clientY - canvasOffset.y;
if (drawing) {
ctx.strokeStyle = "#000000";
ctx.beginPath();
ctx.moveTo(position.x, position.y);
ctx.lineTo(mousex, mousey);
ctx.stroke();
}
setPosition({ x: mousex, y: mousey });
}
Also, we will need to set the position once the mouse is clicked to have a position to move to for our next stroke, so we need to modify our handleMouseDown function.
function handleMouseDown(e) {
setDrawing(true);
setPosition({
x: parseInt(e.clientX - canvasOffset.x),
y: parseInt(e.clientY - canvasOffset.y),
});
}
Cool! Now, let's add some CSS to our App.css
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
}
.app {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
}
And our Board.css
.board {
background-color: white;
cursor: crosshair;
margin: 0 auto;
position: relative;
width: 100%;
overflow: hidden;
flex: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
All of that is wonderful and I can draw on my whiteboard, but there was one problem I was left to struggle with. I don't work with canvas often, so I spent some significant amount of time trying to figure out why the lines look pixelated and realizing how much I love backend development. I found out that this was because I was styling the canvas height with CSS and somehow that messes it up and I should just dynamically assign the window's inner width and inner height to the canvas width and height attributes or the parent's offset width and offset height.
To achieve that, let's add a new ref for the canvas parent to be able to access its offset width and height!
const parentRef = React.useRef(null);
We should also add it to the parent element.
return (
<div className="board" ref={parentRef}>
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
/>
</div>
);
We can assign the width and height right before we set our context.
useEffect(() => {
let canv = canvasRef.current;
canv.width = parentRef.current.offsetWidth;
canv.height = parentRef.current.offsetHeight;
let canvCtx = canv.getContext("2d");
canvCtx.lineJoin = "round";
canvCtx.lineCap = "round";
canvCtx.lineWidth = 5;
setCtx(canvCtx);
let offset = canv.getBoundingClientRect();
setCanvasOffset({ x: parseInt(offset.left), y: parseInt(offset.top) });
}, [ctx]);
Lovely! Now we can draw freely on our board!
Controls
It's time to take our whiteboard a step further and add the Controls component. It will only have a couple of buttons, so I designed it to lay on top of the canvas.
In the Controls component we will just add a simple structure to contain our buttons
import React from "react";
import "./Controls.css";
function Controls() {
return <div className="controls"></div>;
}
export default Controls;
And add some CSS in Controls.css to position it on our canvas
.controls {
position: absolute;
top: 0;
display: flex;
justify-content: center;
width: auto;
}
Color
Let's move on to our Color component! We need a color picker. I chose the react-color package, which can be installed by running:
npm install react-color --save
While we're at it, I also want to add icons to the controls, so we can install the react-fontawesome package by running:
npm i --save @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome
Let's start by importing Font Awesome and adding the icon for the color!
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPalette } from "@fortawesome/free-solid-svg-icons";
function Color() {
return (
<div className="color">
<FontAwesomeIcon
title="choose color"
className="fa-icon"
icon={faPalette}
/>
</div>
);
}
export default Color;
Now, we need to add the color picker. I like the way the ChromePicker looks so I will import it.
I only want the picker to pop up once I click the palette icon, so I will need to add a flag to detect whether it was clicked or not, some custom CSS and handle click events.
import React, { useState } from "react";
import { ChromePicker } from "react-color";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPalette } from "@fortawesome/free-solid-svg-icons";
function Color(props) {
const popover = {
position: "absolute",
zIndex: "2",
};
const cover = {
position: "fixed",
top: "0px",
right: "0px",
bottom: "0px",
left: "0px",
};
const [displayed, setDisplayed] = useState(false);
function handleClick() {
setDisplayed(true);
}
function handleClose() {
setDisplayed(false);
}
return (
<div className="color">
<FontAwesomeIcon
onClick={handleClick}
title="choose color"
className="fa-icon"
icon={faPalette}
/>
{displayed ? (
<div style={popover}>
<div style={cover} onClick={handleClose} />
<ChromePicker />
</div>
) : null}
</div>
);
}
export default Color;
Good! Now let's add our Color component to our Controls component
import React from "react";
import "./Controls.css";
import Color from "../Color/Color";
function Controls() {
return <div className="controls">
<Color />
</div>;
}
export default Controls;
And our Controls component to our Board component to see how far we've gone.
return (
<div className="board" ref={parentRef}>
<Controls />
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
/>
</div>
);
Okay, now, we need to add another thing to our Board component. We haven't handled how the selected color we choose from the color picker is going to reflect in drawing on our board.
Let's use our Hooks to track our color value and set its default value as black.
const [color, setColor] = useState("#000000");
Now let's modify our handleMouseMove function to have the strokeStyle set as the color state!
function handleMouseMove(e) {
let mousex = e.clientX - canvasOffset.x;
let mousey = e.clientY - canvasOffset.y;
if (drawing) {
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(position.x, position.y);
ctx.lineTo(mousex, mousey);
ctx.stroke();
}
setPosition({ x: mousex, y: mousey });
}
One more thing, we want that color state to be updated when the color picker changes, so we are going to add another function that handles that and send it to our Controls component as a prop and from there, also send it to the Color component as a prop.
function handleColor(color) {
setColor(color);
}
return (
<div className="board" ref={parentRef}>
<Controls handleColor={handleColor} />
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
/>
</div>
);
And in our Controls component, let's pass the prop to the Color component!
function Controls(props) {
return <div className="controls">
<Color handleColor={props.handleColor} />
</div>;
}
Now, let's go back to our Color component and add a state to track the color changes!
const [color, setColor] = useState("#000000");
After that, we can handle our color picker change using our prop. We want the hex value of the color which is enclosed in the parameter sent to the handleChange function.
function handleChange(pickerColor) {
setColor(pickerColor.hex);
props.handleColor(pickerColor.hex);
}
We also want to update our picker itself with the selected color.
<ChromePicker color={color} onChange={handleChange} />
Perfect! Now, our color is reflecting! Let's add some CSS in our Controls.css for our button to look pretty.
.controls .fa-icon {
cursor: pointer;
font-size: 3rem;
margin: 0.5rem;
padding: 0.5rem;
border-radius: 30%;
box-shadow: 0 0 6px black;
z-index: 2;
color: #071a54;
background: linear-gradient(
90deg,
rgba(174, 238, 237, 1) 0%,
rgba(181, 23, 23, 1) 100%
);
}
Eraser
Our work is almost done, now we just need to be able to use our eraser. I am going to cheat here and just change the color to white. We can use the ctx.globalCompositeOperation = 'destination-out';
method, but changing the color to white would just do the trick for us.
Our component will look like this
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEraser } from "@fortawesome/free-solid-svg-icons";
function Eraser(props) {
function handleEraser(e) {
e.preventDefault();
props.handleColor("#ffffff");
}
return (
<div className="eraser">
<FontAwesomeIcon
title="erase"
icon={faEraser}
className="fa-icon"
onClick={handleEraser}
/>
</div>
);
}
export default Eraser;
In our Controls component, we will pass the same prop we passed to our Color component to make it reflect in our board as we draw.
import React from "react";
import "./Controls.css";
import Color from "../Color/Color";
import Eraser from "../Eraser/Eraser";
function Controls(props) {
return (
<div className="controls">
<Color handleColor={props.handleColor} />
<Eraser handleColor={props.handleColor} />
</div>
);
}
export default Controls;
And here it is! Our fully functional whiteboard!
The code can be found HERE
By this mini whiteboard, I shall end my fifth baby step towards React greatness, until we meet in another one.
Any feedback or advice is always welcome. Reach out to me here, on Twitter, there and everywhere!