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!
Why tho?
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.
Why not tho?
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 😁
The code!
First, let's introduce the code:
selbekk / react-christmas
Get in the Spirit of Composititon
react.christmas
Development
To run a development server, run yarn dev
.
Deployment
Deploy with yarn deploy
.
Create your own!
Fork this project, and change the stuff in ./config.js
to get started. If you find any more React-specific after that,
please submit a pull request that moves those texts etc into ./config.js
.
Write content
All content is found in the ./content/
folder, categorized by year. If you want to add articles from - let's say 2018,
create a folder named ./content/2018
and start creating Markdown files.
The markdown files should be named 01.md
, 02.md
etc - all the way up to 24.md
. Each article should start with some
metadata in the Frontmatter format - it looks like this:
title: Get started with create-react-app
lead: Creating your first React app usually starts off with a 30 minute crash course with Webpack, Babel and a whole lot
…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.
Remember to upgrade 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)
Refactoring a BackgroundImage component
The first component I rewrote was a BackgroundImage
component. It did the following:
- When it mounts, check the screen size.
- If the screen size is less than 1500 px, request a properly scaled version of the image.
- If the screen size is 1500 px or wider, do nothing
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.
- Won't I always download the largest picture first, this way?
- What if the window size changes?
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} />;
}
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} />;
}
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} />;
}
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.
A page tracking hook
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]);
}
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 />
</>
);
}
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.
Summary
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!
Top comments (4)
Curious about the background image component - is there any reason you didn't use an img element with
srcset
for this functionality?Great question!
In this case, there were two reasons. Firstly, I animate the transition from one image to the other. Secondly, I wanted to use the image as a background image (and therefore leverage background-size: cover).
Also, I didn’t think of the srcset prop, to be honest 🙈
Cool thanks for answering so quickly! In case you end up in this situation again and feel like you want to avoid inline styles here, you can use
object-fit: cover
in css to emulate the same behavior asbackground-size: cover
on a native image element. This is pretty helpful because it lets you get that behavior and also take advantage of the performance benefits ofsrcset
😀Great article! That usePageTracking hook is 👌.