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>
</>
)
}
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)
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)
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)
}
We need to use useEffect
here to return a clean-up function to prevent memory leaks when the component is unmounted.
React.useEffect(() => stopCounting, [])
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)
}
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 }
}
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>
</>
)
}
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>
</>
)
}
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>
</>
)
}
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>
</>
)
}
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>
</>
)
}
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>
</>
)
}
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>
)
}
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 (19)
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.
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.
In order to cover testing, why don't we just take one example and test it thoroughly. Would make for Ann engaging second article!
Done.
dev.to/namick/writing-your-own-rea...
Cheers!
Epic!
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.
You're totally right, it's an amazing feeling!
Brilliantly explained, thank you!
Thanks, it feels good to know that you like it. :-)
Very good explanation, I need this kind of post related to react hooks ,life cycle method, redux....etc
overreacted.io/a-complete-guide-to...
I very much recommend this post.
Thanks. Do you find yourself needing classes and life cycle methods anymore with hooks now available?
just had to jump down here to say "excellent hero image" 😉
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. :-)
I see you returning setRunningTime. Do useState setFunctions return a value?
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
Does the setter function return a value? You know, in addition to setting one
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.Why create one when you can get all awesome hooks in a single library?
Try scriptkavi/hooks. Copy paste style and easy to integrate with its own CLI