In my last post I gave a brief overview of the many tools React Hooks provides which allow developers to create stateful functional components that can mimic the the lifecycle events of class components.
Building off of this, I'd like to now give an example of a more complex piece of UI build entirely using React Hooks to demonstrate how React Hooks might look in more complicated components.
The Component
In a recent project we had to implement a React component that the user could rotate clicking and dragging the mouse. Here's an example of how this functionality could be implemented using React classes.
class App extends Component {
constructor() {
super();
this.box = null;
this.state = {
isActive: false,
angle: 0,
startAngle: 0,
currentAngle: 0,
boxCenterPoint: {}
};
this.getPositionFromCenter = this.getPositionFromCenter.bind(this);
this.mouseDownHandler = this.mouseDownHandler.bind(this);
this.mouseUpHandler = this.mouseUpHandler.bind(this);
this.mouseMoveHandler = this.mouseMoveHandler.bind(this);
this.deslectAll = this.deslectAll.bind(this);
}
// to avoid unwanted behaviour, deselect all text
deslectAll() {
if (document.selection) {
document.selection.empty();
} else if (window.getSelection) {
window.getSelection().removeAllRanges();
}
}
// method to get the positionof the pointer event relative to the center of the box
getPositionFromCenter(e) {
const { boxCenterPoint } = this.state;
const fromBoxCenter = {
x: e.clientX - boxCenterPoint.x,
y: -(e.clientY - boxCenterPoint.y)
};
return fromBoxCenter;
}
mouseDownHandler(e) {
e.stopPropagation();
const fromBoxCenter = this.getPositionFromCenter(e);
const newStartAngle =
90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
this.setState({
startAngle: newStartAngle,
isActive: true
});
}
mouseUpHandler(e) {
this.deslectAll();
e.stopPropagation();
const { isActive, angle, startAngle, currentAngle } = this.state;
if (isActive) {
const newCurrentAngle = currentAngle + (angle - startAngle);
this.setState({
isActive: false,
currentAngle: newCurrentAngle
});
}
}
mouseMoveHandler(e) {
const { isActive, currentAngle, startAngle } = this.state;
if (isActive) {
const fromBoxCenter = this.getPositionFromCenter(e);
const newAngle =
90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
console;
this.box.style.transform =
"rotate(" +
(currentAngle + (newAngle - (startAngle ? startAngle : 0))) +
"deg)";
this.setState({ angle: newAngle });
} // active conditional
}
componentDidMount() {
const boxPosition = this.box.getBoundingClientRect();
// get the current center point
const boxCenterX = boxPosition.left + boxPosition.width / 2;
const boxCenterY = boxPosition.top + boxPosition.height / 2;
// update the state
this.setState({
boxCenterPoint: { x: boxCenterX, y: boxCenterY }
});
// in case the event ends outside the box
window.onmouseup = this.mouseUpHandler;
window.onmousemove = this.mouseMoveHandler;
}
render() {
return (
<div className="row">
<div className="col-12">
<h2 className="text-center">React Rotation</h2>
<p>Click and rotate the blue square.</p>
<p>Sample for this question in StackOverflow:</p>
<p>
<a
href="https://stackoverflow.com/questions/45193647/reactjs-rotate-functionality-not-working"
target="_blank"
>
https://stackoverflow.com/questions/45193647/reactjs-rotate-functionality-not-working
</a>
</p>
<div
className="box"
onMouseDown={this.mouseDownHandler}
onMouseUp={this.mouseUpHandler}
ref={div => (this.box = div)}
>
Rotate
</div>
</div>
</div>
);
}
}
Try it here
Here we have a div called "box". When the user clicks on the box they can drag the mouse to rotate it. When they let go of the mouse button the box retains its new rotation. This functionality is created using a number of variables tracked in the component state and a ref used to set up the event handlers that rotate the box.
When the component is first mounted, we calculate its center point and store it in component state as boxCenterPoint
.
When mouseDownHandler
is fired we set the isActive
state to true to enable the mouseMoveHandler
to fire when the user moves the cursor. We also calculate the current angle of the cursor relative to the boxCenterPoint
and store the value as startAngle
to be used in determining how much we need to rotate the box when the user moves the mouse.
Next, mouseMoveHandler
is called when the user moves the cursor after pressing down the mouse button. When the cursor moves, the new angle of the cursor from the center point of the box is calculated, and using the startAngle
stored in state the app calculates how much it needs to rotate the box using the ref we set up.
And finally, when the user releases the mouse button mouseUpHandler
is called which resets isActive
to false and sets the new currentAngle
to the current rotation of box calculated from the old currentAngle
+ the angle of rotation of the drag and drop action (the difference between angle
and startAngle
).
To summarize:
isActive
- a boolean that limits when mouseDownHandler
is fired
boxCenterPoint
- the center coordinates of the box div
currentAngle
- the rotation angle of the box when the user is not rotating it (when isActive = false
)
startAngle
- the angle of the cursor relative to the boxCenterPoint
when mouseDownHandler is called
angle
- the angle of the cursor relative to the boxCenterPoint
when mouseMoveHandler is called (when isActive = true
)
Switching To React Hooks
If you would like to simple see the React Hooks version of this component you can find the code at the end of this post. Otherwise, we're going to go through the process of copying this component step by step into a functional component.
Let’s begin with a simple presentational component
CSS:
.box {
height: 100px;
width: 100px;
border-radius: 10px;
cursor: pointer;
background: blue;
color: white;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.box-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
Javascript:
import React from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const App = () => {
return (
<div className="box-container">
<div className="box">Rotate</div>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Using the original class component version of the rotating div as a reference point let's add all of the state variables next.
We can also pass a ref to the box div
using useRef
import React, { useState, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const App = () => {
const box = useRef(null);
const [isActive, setIsActive] = useState(false);
const [angle, setAngle] = useState(0);
const [startAngle, setStartAngle] = useState(0);
const [currentAngle, setCurrentAngle] = useState(0);
const [boxCenterPoint, setBoxCenterPoint] = useState({});
return (
<div className="box-container">
<div
className="box"
ref={box}
>
Rotate
</div>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Now we have all the state we need to work with. To start, let's set the boxCenterPoint
when the component first mounts. Here was the logic from the class component.
componentDidMount() {
const boxPosition = this.box.getBoundingClientRect();
// get the current center point
const boxCenterX = boxPosition.left + boxPosition.width / 2;
const boxCenterY = boxPosition.top + boxPosition.height / 2;
// update the state
this.setState({
boxCenterPoint: { x: boxCenterX, y: boxCenterY }
});
}
To mimic the componentDidMount
lifecycle method we call useEffect
and pass it an empty dependency array as a second argument. When the component mounts
useEffect(() => {
const boxPosition = box.current.getBoundingClientRect();
// get the current center point
const boxCenterX = boxPosition.left + boxPosition.width / 2;
const boxCenterY = boxPosition.top + boxPosition.height / 2;
// update the state
setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
}, []);
This will set the boxCenterPoint when the component first mounts using the setBoxCenterPoint
method returned from useState
.
Now we can copy over the mouseDownHandler
and getPositionFromCenter
.
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const App = () => {
const box = useRef(null);
const [isActive, setIsActive] = useState(false);
const [angle, setAngle] = useState(0);
const [startAngle, setStartAngle] = useState(0);
const [currentAngle, setCurrentAngle] = useState(0);
const [boxCenterPoint, setBoxCenterPoint] = useState({});
const getPositionFromCenter = e => {
const fromBoxCenter = {
x: e.clientX - boxCenterPoint.x,
y: -(e.clientY - boxCenterPoint.y)
};
return fromBoxCenter;
};
const mouseDownHandler = e => {
e.stopPropagation();
const fromBoxCenter = getPositionFromCenter(e);
const newStartAngle =
90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
setStartAngle(newStartAngle);
setIsActive(true);
console.log(newStartAngle);
};
useEffect(() => {
const boxPosition = box.current.getBoundingClientRect();
// get the current center point
const boxCenterX = boxPosition.left + boxPosition.width / 2;
const boxCenterY = boxPosition.top + boxPosition.height / 2;
// update the state
setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
}, []);
return (
<div className="box-container">
<div className="box" onMouseDown={mouseDownHandler} ref={box}>
Rotate
</div>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Try it out here
With this we have a component that will log the angle of the cursor relative to the center of the box when the user clicks on the box.
Now let's try something more complicated. Let's set up the mouseMoveHandler
. To do this we'll have to set up an event listener that fires whenever the user moves the mouse.
Unlike in the class component, we cannot simply set up this listener one time when the component first mounts. As explained in the previous React Hooks post, Effects only access the state value from the render they immediately follow.
Here's a test mouseMoveHandler
that should only log when the mouse moves and the isActive
state is true
.
const mouseMoveHandler = e => {
if (isActive) {
console.log("mouseMoveHandler does something");
}
};
If we create this Effect to create an event listener when the component mounts,
useEffect(() => {
window.onmousemove = mouseMoveHandler;
}, []);
then mouseMoveHandler
will only ever be called with the initial value of isActive
which is false
. Nothing will ever happen.
To fix this, we should add mouseMoveHandler
to the Effect's dependency array
useEffect(() => {
window.onmousemove = mouseMoveHandler;
}, [mouseMoveHandler]);
Now this Effect will update the onmousemove
event listener whenever mouseMoveHandler
is updated. This is a step in the right direction, but there's one issue we still need to address. Currently mouseMoveHandler
will update on every single rerender which will update the Effect on every single rerender. The Effect we've made is the exact same as this Effect without a dependency array.
useEffect(() => {
window.onmousemove = mouseMoveHandler;
});
React gives us useCallback to limit when methods are updated in the exact same way we limit when Effects run with useEffect
. Here's how we limit when mouseMoveHandler
updates.
const mouseMoveHandler = useCallback(
e => {
if (isActive) {
console.log("mouseMoveHandler does something");
}
},
[isActive]
);
useCallback
accepts 2 arguments, a callback that performs all the intended logic of the method, and a dependency array. It returns a memoized version of the callback that will only update when the variables specified in the dependency array update.
With this mouseMoveHandler
will finally log its message when you move the cursor after you click on the box. In fact, it will not stop logging its message after you click on the box since we have yet to create a mouseUpHandler
that will set isActive
to false
.
Let's do that now..
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const App = () => {
const box = useRef(null);
const [isActive, setIsActive] = useState(false);
const [angle, setAngle] = useState(0);
const [startAngle, setStartAngle] = useState(0);
const [currentAngle, setCurrentAngle] = useState(0);
const [boxCenterPoint, setBoxCenterPoint] = useState({});
const getPositionFromCenter = e => {
const fromBoxCenter = {
x: e.clientX - boxCenterPoint.x,
y: -(e.clientY - boxCenterPoint.y)
};
return fromBoxCenter;
};
const mouseDownHandler = e => {
e.stopPropagation();
const fromBoxCenter = getPositionFromCenter(e);
const newStartAngle =
90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
setStartAngle(newStartAngle);
setIsActive(true);
};
const mouseUpHandler = useCallback(
e => {
e.stopPropagation();
if (isActive) {
setIsActive(false);
}
},
[isActive]
);
const mouseMoveHandler = useCallback(
e => {
if (isActive) {
console.log("mouseMoveHandler does something");
}
},
[isActive]
);
useEffect(() => {
const boxPosition = box.current.getBoundingClientRect();
// get the current center point
const boxCenterX = boxPosition.left + boxPosition.width / 2;
const boxCenterY = boxPosition.top + boxPosition.height / 2;
// update the state
setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
}, []);
useEffect(() => {
// in case the event ends outside the box
window.onmouseup = mouseUpHandler;
window.onmousemove = mouseMoveHandler;
}, [mouseUpHandler, mouseMoveHandler]);
return (
<div className="box-container">
<div className="box" onMouseDown={mouseDownHandler} ref={box}>
Rotate
</div>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Note that we've added mouseUpHandler
to the Effect's dependency array. With this the mouseMoveHandler
will only fire when the use has the mouse button held down after clicking on the box.
Let's get the box rotating!
Now that we have our event listeners set up, let's copy over the logic for mouseMoveHandler
and mouseUpHandler
from our class component template. With this our rotating div functional component will be complete.
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const App = () => {
const box = useRef(null);
const [isActive, setIsActive] = useState(false);
const [angle, setAngle] = useState(0);
const [startAngle, setStartAngle] = useState(0);
const [currentAngle, setCurrentAngle] = useState(0);
const [boxCenterPoint, setBoxCenterPoint] = useState({});
// to avoid unwanted behaviour, deselect all text
const deselectAll = () => {
if (document.selection) {
document.selection.empty();
} else if (window.getSelection) {
window.getSelection().removeAllRanges();
}
};
// method to get the positionof the pointer event relative to the center of the box
const getPositionFromCenter = useCallback(
e => {
const fromBoxCenter = {
x: e.clientX - boxCenterPoint.x,
y: -(e.clientY - boxCenterPoint.y)
};
return fromBoxCenter;
},
[boxCenterPoint]
);
const mouseDownHandler = e => {
e.stopPropagation();
const fromBoxCenter = getPositionFromCenter(e);
const newStartAngle =
90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
setStartAngle(newStartAngle);
setIsActive(true);
};
const mouseUpHandler = useCallback(
e => {
deselectAll();
e.stopPropagation();
if (isActive) {
const newCurrentAngle = currentAngle + (angle - startAngle);
setIsActive(false);
setCurrentAngle(newCurrentAngle);
}
},
[isActive, angle, currentAngle, startAngle]
);
const mouseMoveHandler = useCallback(
e => {
if (isActive) {
const fromBoxCenter = getPositionFromCenter(e);
const newAngle =
90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
box.current.style.transform =
"rotate(" +
(currentAngle + (newAngle - (startAngle ? startAngle : 0))) +
"deg)";
setAngle(newAngle);
}
},
[isActive, currentAngle, startAngle, getPositionFromCenter]
);
useEffect(() => {
const boxPosition = box.current.getBoundingClientRect();
// get the current center point
const boxCenterX = boxPosition.left + boxPosition.width / 2;
const boxCenterY = boxPosition.top + boxPosition.height / 2;
// update the state
setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
}, []);
useEffect(() => {
// in case the event ends outside the box
window.onmouseup = mouseUpHandler;
window.onmousemove = mouseMoveHandler;
}, [mouseUpHandler, mouseMoveHandler]);
return (
<div className="box-container">
<div
className="box"
onMouseDown={mouseDownHandler}
onMouseUp={mouseUpHandler}
ref={box}
>
Rotate
</div>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
As a final note, pay attention to the dependency arrays for the mouseUpHandler
and mouseMoveHandler
. As a general rule, it is always best to specify every single prop, method, and/or piece of state that a method or Effect will use in the dependency array of useCallback
or useEffect
so that the methods and Effects can be updated appropriately when the component rerenders. The Eslint rule react-hooks/exhaustive-deps
can be helpful in identifying parts of your code where you may have missed a dependency in your dependency array.