DEV Community

Cover image for Why You Should be Writing Your Own React Hooks
nathan amick
nathan amick

Posted on

Why You Should be Writing Your Own React Hooks

tl;dr

Custom React hooks can provide a great place to draw a boundary between imperative and declarative code.

In this example, we'll look at extracting essential complexity into composable, encapsulated, reusable objects while keeping your components clean and declarative.


Composability

Trick question: what is the one place you can use React hooks outside of a Component? The answer, of course, is in other hooks.

As you likely know, when you write your own hooks you are writing plain old Javascript functions that follow the convention of React Hooks. They don't have a specific signature; there is nothing special about them and you can use them however you need to.

As you build an app, adding features and making it more useful, components tend to take on more complexity. Experience helps you prevent avoidable complexity, but this only goes so far. A certain amount of complexity is necessary.

It's a great feeling to take some messy but necessary logic scattered around a component and wrap it up in a hook with a clear API and single purpose.

Let's look at a simple stopwatch component. Here is the implementation in codesandbox to play with.

And this is the code.

function App() {
  return (
    <div className="App">
      <Stopwatch />
    </div>
  )
}

function Stopwatch() {
  const [isCounting, setIsCounting] = React.useState(false)
  const [runningTime, setRunningTime] = React.useState(0)

  const intervalId = React.useRef()

  const startCounting = () =>
    (intervalId.current = setInterval(intervalCallback(), 0))

  const stopCounting = () => clearInterval(intervalId.current)

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(runningTime + new Date().getTime() - startTime)
  }

  React.useEffect(() => stopCounting, [])

  const handleStartStop = () => {
    isCounting ? stopCounting() : startCounting()
    setIsCounting(!isCounting)
  }

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(0)
  }

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Quick explanation of the component

Let's walk through the code really quick so we are all on the same page.

We start off with a couple of useState hooks to keep track of if and how long the timer has been running.

const [isCounting, setIsCounting] = React.useState(false)
const [runningTime, setRunningTime] = React.useState(0)
Enter fullscreen mode Exit fullscreen mode

Next we have a couple of functions that start and stop the timer by setting and clearing an interval. We store the interval ID as a Ref because we need a bit of state, but we don't care about it triggering a rerender.

We are not using setInterval to do any timing, we just need it to repeatedly call a function without blocking.

  const intervalId = React.useRef()

  const startCounting = () =>
    (intervalId.current = setInterval(intervalCallback(), 0))

  const stopCounting = () => clearInterval(intervalId.current)
Enter fullscreen mode Exit fullscreen mode

The time counting logic is in a callback which gets returned by this function and passed to setInterval. It closes over startTime at the moment the stopwatch is started.

 const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(runningTime + new Date().getTime() - startTime)
  }
Enter fullscreen mode Exit fullscreen mode

We need to use useEffect here to return a clean-up function to prevent memory leaks when the component is unmounted.

  React.useEffect(() => stopCounting, [])
Enter fullscreen mode Exit fullscreen mode

And finally we define a couple of handlers for our start/stop and reset buttons.

  const handleStartStop = () => {
    isCounting ? stopCounting() : startCounting()
    setIsCounting(!isCounting)
  }

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(0)
  }
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward, but the component is handling multiple concerns.
This code knows too much. It knows how to start and stop counting time and how it should be laid out on the page. We know we should refactor it, but let's think about why.

There are two main reasons we might want to extract this logic out, so we can add unrelated features, and so we can add similar components that use this same feature.

The first reason is that when we need to add more features, we don't want the component to grow out of control and be difficult to reason about. We want to encapsulate this timer logic so that new, unrelated logic doesn't get mixed in with this logic. This is adhering to the single responsibility principle.

The second reason is for simple reuse without repeating ourselves.

As a side note, if the code in question didn't contain any hooks, we could just extract it into a normal function.

As it is, we'll need to extract it into our own hook.

Let's do that.

const useClock = () => {
  const [isCounting, setIsCounting] = React.useState(false)
  const [runningTime, setRunningTime] = React.useState(0)

  const intervalId = React.useRef()

  const startCounting = () =>
    (intervalId.current = setInterval(intervalCallback(), 0))

  const stopCounting = () => clearInterval(intervalId.current)

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(runningTime + new Date().getTime() - startTime)
  }

  React.useEffect(() => stopCounting, [])

  const handleStartStop = () => {
    isCounting ? stopCounting() : startCounting()
    setIsCounting(!isCounting)
  }

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(0)
  }

  return { runningTime, handleStartStop, handleReset }
}
Enter fullscreen mode Exit fullscreen mode

Notice we are returning the running time of the clock and our handlers in an object which we immediately destructure in our component like this.

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useClock()

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

So far so good. It works (codesandbox demo), and the immediate benefit is that our component becomes completely declarative, which is the way React components should be. One way to think about this is that the component describes it's final state, that is, all of it's possible states, at the same time. It's declarative because it simply declares how it is, but not the steps it takes to get it into those states.

Adding a Timer

Let's say we don't just need a stopwatch that counts up. We also need a timer that counts down.

We'll need 95% of the Stopwatch logic in the timer, and that should be easy since we just extracted it.

Our first inclination might be to pass it a flag and add the conditional logic where it is needed. Here is the relevant parts of what that might look like.

const useClock = ({ variant }) => {
  // <snip>

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    if (variant === 'Stopwatch') {
      return () =>
        setRunningTime(runningTime + new Date().getTime() - startTime)
    } else if (variant === 'Timer') {
      return () =>
        setRunningTime(runningTime - new Date().getTime() + startTime)
    }
  }

  // <snip>
}

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    variant: 'Stopwatch',
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

function Timer() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    variant: 'Timer',
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

OK, this works (codesandbox demo), but we can see that it is already getting harder to read. If we had several more of these "features" its going to get out of control.

A better way might be to extract out the unique part, give it a name (not always easy) and pass it into our hook, like this.

const useClock = ({ counter }) => {
  // <snip>

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(counter(startTime, runningTime))
  }

  // <snip>
}

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    counter: (startTime, runningTime) =>
      runningTime + new Date().getTime() - startTime,
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

function Timer() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    counter: (startTime, runningTime) =>
      runningTime - new Date().getTime() + startTime,
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

Enter fullscreen mode Exit fullscreen mode

Awesome, it works (codesandbox demo), and our useClock hook stays nice and clean. It may arguably be more readable than the original since we have named one of its squishy parts.

However, the changes we have introduced to our Stopwatch and Timer components have made them less declarative. This new imperative code is instructing as to how it works, not declaring what it does.

To fix this, we can just push that code out into into a couple more hooks. This demonstrates the beauty of the React hook api; they are composable.

const useStopwatch = () =>
  useClock({
    counter: (startTime, runningTime) =>
      runningTime + new Date().getTime() - startTime,
  })

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useStopwatch()

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

const useTimer = () =>
  useClock({
    counter: (startTime, runningTime) =>
      runningTime - new Date().getTime() + startTime,
  })

function Timer() {
  const { runningTime, handleStartStop, handleReset } = useTimer()

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Much better (codesandbox demo), our components are back to being fully declarative, and our imperative code is nicely encapsulated.

To demonstrate why this is a good thing, lets see how easy it is to add more features without mucking up our code.

Adding a start time

We don't want our timer to count down from zero, so let's add an initial time.

function App() {
  return (
    <div className="App">
      <Stopwatch />
      <Timer initialTime={5 * 1000} />
    </div>
  )
}

const useClock = ({ counter, initialTime = 0 }) => {
  const [isCounting, setIsCounting] = React.useState(false)
  const [runningTime, setRunningTime] = React.useState(initialTime)

  // <snip>

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(initialTime)
  }

  return { runningTime, handleStartStop, handleReset }
}

const useTimer = initialTime =>
  useClock({
    counter: (startTime, runningTime) =>
      runningTime - new Date().getTime() + startTime,
    initialTime,
  })

function Timer({ initialTime }) {
  const { runningTime, handleStartStop, handleReset } = useTimer(initialTime)

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Not too bad (codesandbox). We just added a prop and passed it on to our useClock hook.

Adding Timer Notification

Now we want our Timer component to notify us when the time is up. Ding, Ding!

We'll add a useState hook to the useClock hook to keep track of when our timer runs out.

Additionally, inside a useEffect hook, we need to check if the time is up, stop counting and set isDone to true.

We also switch it back to false in our reset handler.

const useClock = ({ counter, initialTime = 0 }) => {
  // <snip>
  const [isDone, setIsDone] = React.useState(false)

  // <snip>

  React.useEffect(() => {
    if (runningTime <= 0) {
      stopCounting()
      setIsDone(true)
    }
  }, [runningTime])

  // <snip>

  const handleReset = () => {
    // <snip>
    setIsDone(false)
  }

  return { runningTime, handleStartStop, handleReset, isDone }
}

function Timer({ initialTime }) {
  const { runningTime, handleStartStop, handleReset, isDone } = useTimer(initialTime)

  return (
    <>
      {!isDone && <h1>{runningTime}ms</h1>}
      {isDone && <h1>Time's Up!</h1>}
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

That works (codesandbox demo). Notice we didn't need to touch useTimer because we just pass the isDone flag through in the same object.


In the end we have nicely declarative components that are now very easy add styling to.

Our hooks turned out pretty clean too because we didn't add conditional logic but instead we injected the logic that makes them unique.

After moving things into their own modules, and adding some style oriented components with Material-UI our Stopwatch and Timer look like this.

function Stopwatch() {
  const { runningTime, ...other } = useStopwatch()

  return (
    <Clock>
      <TimeDisplay time={runningTime} />
      <Buttons {...other} />
    </Clock>
  )
}

function Timer({ initialTime }) {
  const { runningTime, isDone, ...other } = useTimer(initialTime)

  return (
    <Clock>
      {!isDone && <TimeDisplay time={runningTime} />}
      {isDone && <TimeContainer>Time's Up!</TimeContainer>}
      <Buttons {...other} />
    </Clock>
  )
}

Enter fullscreen mode Exit fullscreen mode

And here is the end result.


Conclusion

Custom React hooks are easy and fun! And they are a great way to hide away imperative code in reusable, composable functions while keeping your components simple and able to cleanly declare what you want your application to look like. Yay.

Top comments (18)

Collapse
 
theodesp profile image
Theofanis Despoudis

The only thing thats left is how to verify it with tests that it works. As it is, it will not pass a code review sadly.

Collapse
 
namick profile image
nathan amick

Indeed, you are correct good sir. I shall endeavor to improve my TDB (test driven blogging) skills. ;-)

But seriously, you bring up a great point. If learners only read about writing tests in blog articles about testing, it gives credence to the idea that testing is just one additional and possibly optional chore rather than an integral and vital part of the development process.

Collapse
 
httpjunkie profile image
Eric Bishard

In order to cover testing, why don't we just take one example and test it thoroughly. Would make for Ann engaging second article!

Collapse
 
namick profile image
nathan amick
Thread Thread
 
theodesp profile image
Theofanis Despoudis

Epic!

Collapse
 
fly profile image
joon

The thrills and power you feel when you get the hang of custom hooks... it was quite surreal when you realize you don't need to put 7 useEffects and 16 useStates in a single component :)
And refactoring said components is just.... delicious.

Collapse
 
namick profile image
nathan amick

You're totally right, it's an amazing feeling!

Collapse
 
full_stack_adi profile image
Aditya

Brilliantly explained, thank you!

Collapse
 
namick profile image
nathan amick

Thanks, it feels good to know that you like it. :-)

Collapse
 
gmpravin profile image
Pravin g

Very good explanation, I need this kind of post related to react hooks ,life cycle method, redux....etc

Collapse
 
fly profile image
joon

overreacted.io/a-complete-guide-to...
I very much recommend this post.

Collapse
 
namick profile image
nathan amick

Thanks. Do you find yourself needing classes and life cycle methods anymore with hooks now available?

Collapse
 
tamouse profile image
Tamara Temple

just had to jump down here to say "excellent hero image" 😉

Collapse
 
namick profile image
nathan amick

Ha, yes. I just realized that it's especially fitting for this particular example because Captain Hook is relentlessly pursued by a crocodile who has swallowed a ticking clock. :-)

Collapse
 
seanmclem profile image
Seanmclem • Edited

I see you returning setRunningTime. Do useState setFunctions return a value?

Collapse
 
namick profile image
nathan amick

I'm not sure if I understand your question, but React.useState returns two values in the form of an array. The first is the state value, which should be treated as immutable and the second is a setter function, which is used to update the state value.

More info here: reactjs.org/docs/hooks-state.html

Collapse
 
seanmclem profile image
Seanmclem

Does the setter function return a value? You know, in addition to setting one

Thread Thread
 
namick profile image
nathan amick

Oh, right. No, it doesn't return a useful value (undefined). However, it does automagically update the value of the state variable and cause your component to rerender with that new value.