selbekk

Refactoring a Small Next App to Use Hooks

March 11, 2019
7 min read

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!

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 - 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.

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} />;
}
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.

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]);
}
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.

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!

All rights reserved © 2024