With the advent of Hooks, the preferred way to share logic between components is via reusable custom Hooks. To create truly reusable custom Hooks, you should adopt the tried-and-tested advanced React component patterns. One of these patterns is called the state initializer pattern.
What is the state initializer pattern? How does it work? Why is it important, and more importantly, how is the pattern implemented with Hooks? I hope to provide answers to these questions in this article.
If you’re curious about implementing every advanced React pattern with Hooks, you should get my book, “Reintroducing React.” I discuss the subject with care and detail.
Please note that the following sections of the article assume basic fluency in Hooks.
What does it mean to initialize state?
Generally speaking, to initialize means to set the value of something. Going by this definition, the state initializer pattern exists to make it easy for the consumers of your custom Hook to set the “value of state.”
Note that the state initializer pattern doesn’t give full control over setting value of state every single time; it mostly allows for setting initial state within your custom Hook, and resetting state to the initial default.
While this isn’t the same as full control over setting the state value within your custom Hook, it offers great benefits you’ll soon see.
The demo app
I am going to discuss this subject pragmatically, so here’s the demo app we will work with.
It’s a little contrived, but I promise it takes nothing away from actually understanding the state initializer pattern with Hooks.
What we have here is a glorified counter application. You click the More coffee button and the number of coffee cups increases.
The main App
component utilizes a custom Hook to manage the number of coffee cups. Here’s what the implementation of the custom Hook, useCounter
, looks like:
// the App uses this custom hook to track the count of coffee cups
function useCounter() {
const [count, setCount] = useState(1);
return {
count,
setCount
};
}
A more prudent implementation of the custom Hook above would be memoizing the returned object value from the custom Hook.
// good
return {
count,
setCount
};
// better
return useMemo(() => ({
count,
setCount
}))
Let’s move on.
Explaining the state initializer pattern to a 5-year-old
To the best of my understanding, every human begins life as an infant. Over the course of years, they grow into adults, until they grow old.
In “Reacty” terms, the initial state of a human is being an infant. This state is predefined and can’t be changed; no human comes into the world as a full-grown adult with no childhood.
Thinking in terms of reusable custom Hooks, this would be a terribly flawed design. The initial state of your reusable custom Hooks shouldn’t be set in stone. You should make it possible for the consumers of your reusable custom Hooks to decide what the initial state of the Hook is.
Interestingly, setting the initial state of your custom reusable Hook is not the only requirement the state initializer pattern presents.
Consider the following: as humans grow, there’s no way to reset a full-grown adult back to being an infant (i.e, the initial state). I know it sounds absurd, but this exact feature is implemented in the state initializer pattern.
At any point in time, implementing the state initializer pattern means exposing a reset callback for which consumers of your Hook may reset the state to the initial state whenever they deem fit.
I have now highlighted two requirements, but there’s still one left to address: you must also make it possible for the consumer to perform any side effect just after a reset is carried out.
For example, if you successfully reset a human from adult to infant (the initial state), you need to perform cleanups such as selling the adult’s property, sending a termination email to their place of work, issuing a divorce to their spouse, etc.
An infant doesn’t need those! So, clean up the adult life they had!
In the same vein, when you reset a component to its initial state, in certain use cases, there’s a need for the consumer to perform cleanups. You need to make this functionality available.
There you go! It should now be easier to reason about the state initializer pattern.
Reiterating the requirements
To be sure you didn’t get carried away by the explanation in the section above, here are the requirements fulfilled by the state initializer pattern:
- Allow for configurable initial state
- Expose a reset function handler to the consumer
- Allow for performing any side effects just after a reset
1. Configurable initial state
The first requirement of the pattern happens to be the easiest to resolve. Consider the initial implementation of the custom Hook:
function useCounter () {
const [count, setCount] = useState(1);
return {
count,
setCount
};
}
On line 2, the initial state within the Hook is set.
const [count, setCount] = useState(1)
Instead of hardcoding the initial state, edit the Hook to expect an argument called initialCount
and pass this value to the useState
call.
function useCounter (initialCount) {
const [count, setCount] = useState(initialCount);
return {
count,
setCount
};
}
To be slightly more defensive, set a fallback via the default parameter syntax. This will cater to users who don’t pass this initialCount
argument.
function useCounter (initialCount = 1) {
const [count, setCount] = useState(initialCount);
return {
count,
setCount
};
}
Now the custom Hook should work as before, but with more flexibility on initializing initial state. I’ll go ahead and initialize the number of initial coffees cups to 10, as seen below:
This is exactly how a consumer would initialize state with the implemented functionality. Let’s move on to fulfilling the other requirements.
2. Handling resets
To handle resets, we need to expose a callback the consumer can invoke at any point in time. Here’s how. First, create a function that performs the actual reset within the custom Hook:
function useCounter (initialCount = 1) {
const [count, setCount] = useState(initialCount);
// look here 👇
const reset = useCallback(() => {
setCount(initialCount)
}, [initialCount])
return {
count,
setCount
};
}
We optimize the reset callback by utilizing the useCallback
Hook. Note that within the reset callback is a simple invocation of the state updater, setCount
:
setCount(initialCount)
This is responsible for setting the state to the initial value passed in by the user, or the default you’ve provided via the default parameter syntax. Now, expose this reset callback in the returned object value, as shown below:
...
return {
count,
setCount,
reset
}
Now, any consumer of this custom Hook may retrieve the reset callback and perform a reset whenever the want. Below’s an example:
3. Triggering a side effect after a reset
Finally, we’re on the last requirement of the state initializer pattern. Do you have an idea how this may be done (i.e., trigger a side effect)? It’s a little tricky yet very easy to cater for. First, consider how side effects are triggered in a typical functional component:
useEffect(() => {
// perform side effect here
}, [dependency])
We can safely assume that the consumer of this component will do something similar. What’s there to be exposed from the custom Hook to make this possible?
Well, look in the value passed to the useEffect
array dependency.
You need to expose a dependency — one that only changes when a reset has been triggered internally, i.e., after the consumer invokes the reset callback.
There’s two different ways to approach this. I took the liberty of explaining both in “Reintroducing React.”
However, here’s what I consider the preferred solution:
function useCounter(initialCount = 1) {
const [count, setCount] = useState(initialCount);
// 1. look here 👇
const resetRef = useRef(0);
const reset = useCallback(() => {
setCount(initialCount);
// 2. 👇 update reset count
++resetRef.current;
}, [initialCount]);
return {
count,
setCount,
reset,
resetDep: resetRef.current // 3. 👈 expose this dependency
};
}
If you look in the code above, you’ll find three annotated lines.
First, create a ref
to hold the number of resets that have been triggered. This is done via the useRef
Hook.
...
// 1. look here 👇
const resetRef = useRef(0);
...
Whenever the reset callback is invoked by the user, you need to update the reset ref count.
...
const reset = useCallback(() => {
setCount(initialCount);
// 2. 👇 update reset count
++resetRef.current;
}, [initialCount]);
...
Finally, expose this reset count as resetDep
, reset dependency.
...
return {
count,
setCount,
reset,
resetDep: resetRef.current // 3. 👈 expose this dependency
};
...
The user may then retrieve this reset dependency, resetDep
, and perform a side effect only when this value changes.
This begs the question, how will the consumer use this exposed resetDep
? I’ll go a bit further to explain how this reset dependency would be consumed by the consumer of your custom Hook.
Quick teaser: Do you think the solution below would work?
// consumer's app
const { resetDep } = useCounter()
useEffect(() => {
// side effect after reset
}, [resetDep])
Unfortunately, that’s not going to work as intended. So, what’s wrong with the solution above?
The problem here is that useEffect
is always first triggered when the component first mounts! Consequently, the reset side effect will be triggered on mount and, subsequently, whenever the resetDep
changes.
This isn’t the behavior we seek; we don’t want the reset side effect triggered on mount. To fix this, the user may provide a check for when the component just mounts, and only trigger the effect function afterwards.
Here’s a solution:
// consumer's app
const {resetDep} = useCounter()
// boolean ref. default to true
const componentJustMounted = useRef(true)
useEffect(() => {
if(!componentJustMounted) {
// perform side effect
//only when the component isn't just mounted
}
// if not set boolean ref to false.
componentJustMounted.current = false;
}, [resetDep])
This isn’t a difficult implementation.
However, if you’ve created a popular reusable Hook or just want to expose an easier API for the consumers of the Hook, then you may wrap and expose all the functionality above in another custom Hook to be used by the consumer — something like useEffectAfterMount
.
Regardless, the implementation of the reset dependency stands. No changes need to be made internally.
Conclusion
Design patterns exist to provide consistent solutions to common problems. Advanced React design patterns also exist for providing consistent solutions to building truly reusable components.
Want to learn more about building truly reusable Hooks? Check out my latest book, “Reintroducing React.”
Catch you later!
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
The post Simplifying state initializers with React Hooks appeared first on LogRocket Blog.
Top comments (0)