Enough of these contrived examples. Let's look at how I refactored a small React app to use Hooks
When the React Core team launched the concept of hooks, I was all on board within a few minutes of reading the docs. Keeping everything as simple functions instead of dealing with classes, this
-binding and lifecycle methods just seemed fantastic to me.
If you're not familiar with hooks, I suggest you visit the official docs. They're a great (albeit long) read, which will make you feel like you know what hooks are and how they're used.
Just about the same time as hooks came out, though, my paternity leave started. I'm lucky enough to get ~6 months of paid leave to stay at home with my son! It's a lot of fun, a lot of poopy diapers and a lot of sleep deprivation. And no hooks at all.
Caring for my son means I don't really have a lot of spare time to play with new APIs, and I don't have any "professional" projects to introduce them to. The last couple of days, however, he's been sleeping better - leaving me with a few hours to kill. Hello hooks!
Just over two years ago, I bought a 3 liter box of wine and a domain name. react.christmas. I decided to create an Advent calendar with React articles, and threw together an app in a few night's time. It's based on Next.js - a server-side rendering React framework - and is pretty simple, really.
In other words - a perfect candidate for a hooks-refactor.
This article will outline the process I went through refactoring this entire app. It seems like a daunting task, but it honestly wasn't that much work. Hope it'll inspire you to do something similar!
As the React Core team keeps on iterating, you shouldn't refactor your existing code to use hooks. The reason they suggest this, is because there's no real need for it. Class components are here to stay (at least for the foreseeable future), and you gain very little (if any) performance from using hooks. In other words, it would be a refactor without any clear value. Well, at least, on the surface.
My argument for refactoring old class-based components to use these new hooks is simple: It's good practice! Since I don't have any time to work on any real projects now, this small refactor is just what I need to solidify what I've read. If you got some time to spare at your job, I suggest you consider to do the same.
Note that you can't use hooks in class components. If you're refactoring HOCs and render-props based components to custom hooks, you won't be able to use those in class components. There are ways around this, but for now, just use some caution. Or refactor all of your code, of course 😁
First, let's introduce the code - you'll find it on GitHub!
The app is actually pretty simple. It has a folder of Markdown-formatted content, which is exposed over an API to the Next.js application. The backend is a simple Express server, and the front-end is pretty simple as well.
As a matter of fact, the code was so simple, there weren't really a lot of class components to refactor! There was a few though, and I'm going to go through them all.
react
and react-dom
In order to use hooks, we need to use a React version that supports them. After a lot of Twitter hype, they were finally released in 16.8.0. So the first thing I did was to update my React deps:
- "react": "^16.4.1",
- "react-dom": "^16.4.1",
+ "react": "^16.8.3",
+ "react-dom": "^16.8.3",
(yes, I know the version range would allow me to run an npm update
here, but I love to be explicit about version requirements)
The first component I rewrote was a BackgroundImage
component. It did the following:
The code looked something like this:
class BackgroundImage extends React.Component {
state = { width: 1500 }
componentDidMount() {
this.setState({
width: Math.min(window.innerWidth, 1500)
});
}
render() {
const src = `${this.props.src}?width=${this.state.width}`;
return (
<Image src={src} />
);
}
}
Rewriting this component to a custom hook wasn't all that hard. It kept some state around, it set that state on mount, and rendered an image that was dependent on that state.
My first approach rewriting this looked something like this:
function BackgroundImage(props) {
const [width, setWidth] = useState(1500);
useEffect(() => setWidth(Math.min(window.innerWidth, 1500)), []);
const src = `${props.src}?width=${width}`;
return <Image src={src} />;
}
I use the useState
hook to remember my width, I default it to 1500 px, and then I use the useEffect
hook to set it to the size of the window once it has mounted.
When I looked at this code, a few issues surfaced, that I hadn't thought about earlier.
Let's deal with the first issue first. Since useEffect
runs after React has flushed its changes to the DOM, the first render will always request the 1500 px version. That's not cool - I want to save the user some bytes if it doesn't need a huge image! So let's optimize this a bit:
function BackgroundImage(props) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, maxWidth),
);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
function BackgroundImage(props) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, maxWidth),
);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
Next up, we want to download a new image if the window size changes due to a resize event:
function BackgroundImage(props) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, 1500),
);
useEffect(() => {
const handleResize = () =>
setCurrentWidth(Math.min(window.innerWidth, 1500));
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
This works fine, but we'll request a ton of images while resizing. Let's debounce this event handler, so we only request a new image at max once per second:
import debounce from 'debounce'; // or write your own
function BackgroundImage(props) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, 1500),
);
useEffect(() => {
// Only call this handleResize function once every second
const handleResize = debounce(
() => setCurrentWidth(Math.min(window.innerWidth, 1500)),
1000,
);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
import debounce from 'debounce'; // or write your own
function BackgroundImage(props) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, 1500),
);
useEffect(() => {
// Only call this handleResize function once every second
const handleResize = debounce(
() => setCurrentWidth(Math.min(window.innerWidth, 1500)),
1000,
);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
Now we're cooking! But now we have a ton of logic in our component, so let's refactor it out into its own hook:
function useBoundedWidth(maxWidth) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, maxWidth),
);
useEffect(() => {
const handleResize = debounce(() => {
const newWidth = Math.min(window.innerWidth, maxWidth);
if (currentWidth > newWidth) {
return; // never go smaller
}
setCurrentWidth(newWidth);
}, 1000);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [maxWidth]);
return currentWidth;
}
function BackgroundImage(props) {
const currentWidth = useBoundedWidth(1500);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
function useBoundedWidth(maxWidth) {
const [currentWidth, setCurrentWidth] = useState(
Math.min(window.innerWidth, maxWidth),
);
useEffect(() => {
const handleResize = debounce(() => {
const newWidth = Math.min(window.innerWidth, maxWidth);
if (currentWidth > newWidth) {
return; // never go smaller
}
setCurrentWidth(newWidth);
}, 1000);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [maxWidth]);
return currentWidth;
}
function BackgroundImage(props) {
const currentWidth = useBoundedWidth(1500);
const src = `${props.src}?width=${currentWidth}`;
return <Image src={src} />;
}
Look at that! Reusable, easy to test, our components look amazing and I think I saw a rainbow at some point. Beautiful!
Note that I also took the opportunity to make sure we never download a smaller image than what we had to begin with. That would just be a waste.
Alright! On to the next component. The next component I wanted to refactor was a page tracking component. Basically, for every navigation event, I pushed an event to my analytics service. The original implementation looked like this:
class PageTracking extends React.Component {
componentDidMount() {
ReactGA.initialize(this.props.trackingId);
ReactGA.pageview(this.props.path);
}
componentDidUpdate(prevProps) {
if (prevProps.path !== this.props.path) {
ReactGA.pageview(this.props.path);
}
}
render() {
return this.props.children;
}
}
Basically this works as a component I wrap my application in. It could also have been implemented as an HOC, if I wanted to.
Since I'm now a hook expert, I immediately recognize that this looks like a prime candidate for a custom hook. So let's start refactoring!
We initialize the analytics service on mount, and register a pageview both on mount and whenever the path changes.
function usePageTracking({ trackingId, path }) {
useEffect(() => {
ReactGA.initialize(trackingId);
}, [trackingId]);
useEffect(() => {
ReactGA.pageview(path);
}, [path]);
}
function usePageTracking({ trackingId, path }) {
useEffect(() => {
ReactGA.initialize(trackingId);
}, [trackingId]);
useEffect(() => {
ReactGA.pageview(path);
}, [path]);
}
That's it! We call useEffect
twice - once to initialize, and once to track the page views. The initialization effect is only called if the trackingId
changes, and the page tracking one is only called when the path
changes.
To use this, we don't have to introduce a "faux" component into our rendering tree, we can just call it in our top level component:
function App(props) {
usePageTracking({ trackingId: 'abc123', path: props.path });
return (
<>
<SiteHeader />
<SiteContent />
<SiteFooter />
</>
);
}
function App(props) {
usePageTracking({ trackingId: 'abc123', path: props.path });
return (
<>
<SiteHeader />
<SiteContent />
<SiteFooter />
</>
);
}
I love how explicit these custom hooks are. You specify what you want to happen, and you specify when you want those effects to re-run.
Refactoring existing code to use hooks can be rewarding and a great learning experience. You don't have to, by any means, and there are some use cases you might want to hold off on migrating - but if you see an opportunity to refactor some code to hooks, do it!
I hope you've learned a bit from how I approached this challenge, and got inspired to do the same in your own code base. Happy hacking!
All rights reserved © 2024