DEV Community

Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Simplifying state initializers with React Hooks

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.

LogRocket Free Trial Banner

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.

Demo App Displaying State Initializer Pattern

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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 
}))
Enter fullscreen mode Exit fullscreen mode

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:

  1. Allow for configurable initial state
  2. Expose a reset function handler to the consumer
  3. 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
  };
}
Enter fullscreen mode Exit fullscreen mode

On line 2, the initial state within the Hook is set.

const [count, setCount] = useState(1)
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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:

Demo Illustrating Configurable Initial State

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 
}
Enter fullscreen mode Exit fullscreen mode

Now, any consumer of this custom Hook may retrieve the reset callback and perform a reset whenever the want. Below’s an example:

Example Of Demo App Handling Resets

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])
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
...
Enter fullscreen mode Exit fullscreen mode

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]);
...
Enter fullscreen mode Exit fullscreen mode

Finally, expose this reset count as resetDep, reset dependency.

...
return {
    count,
    setCount,
    reset,
    resetDep: resetRef.current // 3. 👈 expose this dependency
  };
...
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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 Dashboard Free Trial Banner

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.

Try it for free.


The post Simplifying state initializers with React Hooks appeared first on LogRocket Blog.

Top comments (0)