Micro State Management with React Hooks

By Daishi Kato
    What do you get with a Packt Subscription?

  • Instant access to this title and 7,500+ eBooks & Videos
  • Constantly updated with 100+ new titles each month
  • Breadth and depth in over 1,000+ technologies
  1. Chapter 1: What Is Micro State Management with React Hooks?

About this book

State management is one of the most complex concepts in React. Traditionally, developers have used monolithic state management solutions. Thanks to React Hooks, micro state management is something tuned for moving your application from a monolith to a microservice.

This book provides a hands-on approach to the implementation of micro state management that will have you up and running and productive in no time. You’ll learn basic patterns for state management in React and understand how to overcome the challenges encountered when you need to make the state global. Later chapters will show you how slicing a state into pieces is the way to overcome limitations. Using hooks, you'll see how you can easily reuse logic and have several solutions for specific domains, such as form state and server cache state. Finally, you'll explore how to use libraries such as Zustand, Jotai, and Valtio to organize state and manage development efficiently.

By the end of this React book, you'll have learned how to choose the right global state management solution for your app requirement.

Publication date:
February 2022
Publisher
Packt
Pages
254
ISBN
9781801812375

 

Chapter 1: What Is Micro State Management with React Hooks?

State management is one of the most important topics in developing React apps. Traditionally, state management in React was something monolithic, providing a general framework for state management, and with developers creating purpose-specific solutions within the framework.

The situation changed after React hooks landed. We now have primitive hooks for state management that are reusable and can be used as building blocks to create richer functionalities. This allows us to make state management lightweight or, in other words, micro. Micro state management is more purpose-oriented and used with specific coding patterns, whereas monolithic state management is more general.

In this book, we will explore various patterns of state management with React hooks. Our focus is on global states, in which multiple components can share a state. React hooks already provide good functionality for local states—that is, states within a single component or a small tree of components. Global states are a hard topic in React because React hooks are missing the capability to directly provide global states; it's instead left to the community and ecosystem to deal with them. We will also explore some existing libraries for micro state management, each of which has different purposes and patterns; in this book, we will discuss Zustand, Jotai, Valtio, and React Tracked.

Important Note

This book focuses on a global state and doesn't discuss "general" state management, which is a separate topic. One of the most popular state management libraries is Redux (https://redux.js.org), which uses a one-way data model for state management. Another popular library is XState (https://xstate.js.org), which is an implementation of statecharts, a visual representation of complex states. Both provide sophisticated methods to manage states, which are out of the scope of this book. On the other hand, such libraries also have a capability for a global state. For example, React Redux (https://react-redux.js.org) is a library to bind React and Redux for a global state, which is in the scope of this book. To keep the focus of the book only on a global state, we don't specifically discuss React Redux, which is tied to Redux.

In this chapter, we will define what micro state management is, discuss how React hooks allow micro state management, and why global states are challenging. We will also recap the basic usage of two hooks for state management and compare their similarity and differences.

In this chapter, we will cover the following topics:

  • Understanding micro state management
  • Working with hooks
  • Exploring global states
  • Working with useState
  • Using useReducer
  • Exploring the similarities and differences between useState and useReducer
 

Technical requirements

To run code snippets, you need a React environment—for example, Create React App (https://create-react-app.dev) or CodeSandbox (https://codesandbox.io).

You are expected to have basic knowledge of React and React hooks. More precisely, you should already be familiar with the official React documentation, which you can find here: https://reactjs.org/docs/getting-started.html.

We don't use class components and it's not necessary to learn them unless you need to learn some existing code with class components.

The code in this chapter is available on GitHub at https://github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_01.

 

Understanding micro state management

What is micro state management? There is no officially established definition yet; however, let's try defining one here.

Important Note

This definition may not reflect community standards in the future.

State, in React, is any data that represents the user interface (UI). States can change over time, and React takes care of components to render with the state.

Before we had React hooks, using monolithic state libraries was a popular pattern. A single state covers many purposes for better developer experience, but sometimes it was overkill because the monolithic state libraries can contain unused functionalities. With hooks, we have a new way to create states. This allows us to have different solutions for each specific purpose that you need. Here are some examples of this:

  • Form state should be treated separately from a global state, which is not possible with a single-state solution.
  • Server cache state has some unique characteristics, such as refetching, which is a different feature from other states.
  • Navigation state has a special requirement that the original state resides on the browser end and, again, a single-state solution doesn't fit.

Fixing these issues is one of the goals of React hooks. The trend with React hooks is to handle various states with special solutions for them. There are many hook-based libraries to solve things such as form state, server cache state, and so on.

There's still a need for general state management, as we will need to deal with states that are not covered by purpose-oriented solutions. The proportion of work left for general state management varies on apps. For example, an app that mainly deals with server states would require only one or a few small global states. On the other hand, a rich graphical app would require many large global states compared to server states required in the app.

Hence, solutions for general state management should be lightweight, and developers can choose one based on their requirements. This is what we call micro state management. To define this concept, it's lightweight state management in React, where each solution has several different features, and developers can choose one from possible solutions depending on app requirements.

Micro state management can have several requirements, to fulfill developers' various needs. There are base state management requirements, to do things such as these:

  • Read state
  • Update state
  • Render with state

But there may be additional requirements to do other things, such as these:

  • Optimize re-renders
  • Interact with other systems
  • Async support
  • Derived state
  • Simple syntax; and so on

However, we don't need all features, and some of them may conflict. Hence, a micro state management solution cannot be a single solution either. There are multiple solutions for different requirements.

Another aspect to mention regarding micro state management and its library is its learning curve. Ease of learning is important for general state management too, but as the use cases covered by micro state management can be smaller, it should be easier to learn. An easier learning curve will result in a better developer experience and more productivity.

In this section, we discussed what micro state management is. Coming up, we will see an overview of some hooks that handle states.

 

Working with hooks

React hooks are essential for micro statement management. React hooks include some primitive hooks to implement state management solutions, such as the following:

  • The useState hook is a basic function to create a local state. Thanks to React hooks' composability, we can create a custom hook that can add various features based on useState.
  • The useReducer hook can create a local state too and is often used as a replacement for useState. We will revisit these hooks to learn about the similarities and differences between useState and useReducer later in this chapter.
  • The useEffect hook allows us to run logic outside the React render process. It's especially important to develop a state management library for a global state because it allows us to implement features that work with the React component lifecycle.

The reason why React hooks are novel is that they allow you to extract logic out of UI components. For example, the following is a counter example of the simple usage of the useState hook:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>+1
      </button>
    </div>
  );
};

Now, let's see how we can extract logic. Using the same counter example, we will create a custom hook named useCount, as follows:

const useCount = () => {
  const [count, setCount] = useState(0);
  return [count, setCount];
};
const Component = () => {
  const [count, setCount] = useCount();
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

It doesn't change a lot, and some of you may think this is overcomplicated. However, there are two points to note, as follows:

  • We now have a clearer name—useCount.
  • Component is independent of the implementation of useCount.

The first point is very important for programming in general. If we name the custom hook properly, the code is more readable. Instead of useCount, you could name it useScore, usePercentage, or usePrice. Even though they have the same implementations, if the name is different, we consider it a different hook. Naming things is very important.

The second point is also important when it comes to micro state management libraries. As useCount is extracted from Component, we can add functionality without breaking the component.

For example, we want to output a debug message on the console when the count is changed. To do so, we would execute the following code:

const useCount = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log('count is changed to', count);
  }, [count]);
  return [count, setCount];
};

By just changing useCount, we can add a feature of showing a debug message. We do not need to change the component. This is the benefit of extracting logic as custom hooks.

We could also add a new rule. Suppose we don't want to allow the count to change arbitrarily, but only by increments of one. The following custom hook does the job:

const useCount = () => {
  const [count, setCount] = useState(0);
  const inc = () => setCount((c) => c + 1);
  return [count, inc];
};

This opens up the entire ecosystem to provide custom hooks for various purposes. They can be a wrapper to add a tiny functionality or a huge hook that has a larger job.

You will find many custom hooks publicly available on Node Package Manager (npm) (https://www.npmjs.com/search?q=react%20hooks) or GitHub (https://github.com/search?q=react+hooks&type=repositories).

We should also discuss a little about suspense and concurrent rendering, as React hooks are designed and developed to work with these modes.

Suspense for Data Fetching and Concurrent Rendering

Suspense for Data Fetching and Concurrent Rendering are not yet released by React, but it's important to mention them briefly.

Important Note

Suspense for Data Fetching and Concurrent Rendering may have different names when they are officially released, but these are the names at the time of writing.

Suspense for Data Fetching is a mechanism that basically allows you to code your components without worrying about async.

Concurrent Rendering is a mechanism to split the render process into chunks to avoid blocking the central processing unit (CPU) for long periods of time.

React hooks are designed to work with these mechanisms; however, you need to avoid misusing them.

For example, one rule is that you should not mutate an existing state object or ref object. Doing so may lead to unexpected behavior such as not triggering re-renders, triggering too many re-renders, and triggering partial re-renders (meaning some components re-render while others don't when they should).

Hook functions and component functions can be invoked multiple times. Hence, another rule is those functions have to be "pure" enough so that they behave consistently, even if they are invoked several times.

These are the two major rules people often violate. This is a hard problem in practice, because even if your code violates those rules, it may just work in Non-Concurrent Rendering. Hence, people wouldn't notice the misuse. Even in Concurrent Rendering, it may work to some extent without problems, and people would only see problems occasionally. This makes it especially difficult for beginners who are using React for the first time.

Unless you are familiar with these concepts, it's better to use well-designed and battle-tested (micro) state management libraries for future/newer versions of React.

Important Note

As of writing, Concurrent Rendering is described in the React 18 Working Group, which you can read about here: https://github.com/reactwg/react-18/discussions.

In this section, we revisited basic React hooks and got some understanding of the concepts. Coming up, we start exploring global states, which are the main topic in this book.

 

Exploring global states

React provides primitive hooks such as useState for states that are defined in a component and consumed within the component tree. These are often called local states.

The following example uses a local state:

const Component = () => {
  const [state, setState] = useState();
  return (
    <div>
      {JSON.stringify(state)}
      <Child state={state} setState={setState} />
    </div>
  );
};
const Child = ({ state, setState }) => {
  const setFoo = () => setState(
    (prev) => ({ ...prev, foo: 'foo' })
  );
  return (
    <div>
      {JSON.stringify(state)}
      <button onClick={setFoo}>Set Foo</button>
    </div>
  );
};

On the other hand, a global state is a state that is consumed in multiple components, often far apart in an app. A global state doesn't have to be a singleton, and we may call a global state a shared state instead, to clarify that it's not a singleton.

The following code snippet provides an example of what a React component would look like with a global state:

const Component1 = () => {
  const [state, setState] = useGlobalState();
  return (
    <div>
      {JSON.stringify(state)}
    </div>
  );
};
const Component2 = () => {
  const [state, setState] = useGlobalState();
  return (
    <div>
      {JSON.stringify(state)}
    </div>
  );
};

As we haven't yet defined useGlobalState, it won't work. In this case, we want Component1 and Component2 to have the same state.

Implementing global states in React is not a trivial task. This is mostly because React is based on the component model. In the component model, locality is important, meaning a component should be isolated and should be reusable.

Notes about the Component Model

A component is a reusable piece of a unit, like a function. If you define a component, it can be used many times. This is only possible if a component definition is self-contained. If a component depends on something outside, it may not be reusable because its behavior can be inconsistent. Technically, a component itself should not depend on a global state.

React doesn't provide a direct solution for a global state, and it seems up to the developers and the community. Many solutions have been proposed, and each has its pros and cons. The goal of this book is to show typical solutions and discuss these pros and cons, which we will do in the following chapters:

  • Chapter 3, Sharing Component State with Context
  • Chapter 4, Sharing Module State with Subscription
  • Chapter 5, Sharing Component State with Context and Subscription

In this section, we learned what a global state with React hooks would look like. Coming up, we will learn some basics of useState to prepare the discussion in the following chapters.

 

Working with useState

In this section, we will learn how to use useState, from basic usage to advanced usage. We start with the simplest form, which is updating with the state with a new value, then updating with a function, which is a very powerful feature, and finally, we will discuss lazy initialization.

Updating the state value with a value

One way to update the state value with useState is by providing a new value. You can pass a new value to the function returned by useState that will eventually replace the state value with the new value.

Here is a counter example showing updating with a value:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount(1)}>
        Set Count to 1
      </button>
    </div>
  );
};

You pass a value of 1 to setCount in the onClick handler. If you click the button, it will trigger Component to re-render with count=1.

What would happen if you clicked the button again? It will invoke setCount(1) again, but as it is the same value, it "bails out" and the component won't re-render. Bailout is a technical term in React and basically means avoiding triggering re-renders.

Let's look at another example here:

const Component = () => {
  const [state, setState] = useState({ count: 0 });
  return (
    <div>
      {state.count}
      <button onClick={() => setState({ count: 1 })}>
        Set Count to 1
      </button>
    </div>
  );
};

This behaves exactly the same as the previous example for the first click; however, if you click the button again, the component will re-render. You don't see any difference on screen because the count hasn't changed. This happens because the second click creates a new object, { count: 1 }, and it's different from the previous object.

Now, this leads to the following bad practice:

const Component = () => {
  const [state, setState] = useState({ count: 0 });
  return (
    <div>
      {state.count}
      <button
        onClick={() => { state.count = 1; setState(state); }
      >
        Set Count to 1
      </button>
    </div>
  );
};

This doesn't work as expected. Even if you click the button, it won't re-render. This is because the state object is referentially unchanged, and it bails out, meaning this alone doesn't trigger the re-render.

Finally, there's an interesting usage of value update, which we can see here:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>
        Set Count to {count + 1}
      </button>
    </div>
  );
};

Clicking the button will increment the count; however, if you click the button twice quickly enough, it will increment by just one number. This is sometimes desirable as it matches with the button title, but sometimes it's not if you expect to count how many times the button is actually clicked. That requires a function update.

Updating the state value with a function

Another way to update the state with useState is called a function update.

Here is a counter example showing updating with a function:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};

This actually counts how many times the button is clicked, because (c) => c + 1 is invoked sequentially. As we saw in the previous section, value update has the same use case as the Set Count to {count + 1} feature. In most use cases, function updates work better if the update is based on the previous value. The Set Count to {count + 1} feature actually means that it doesn't depend on the previous value but depends on the displayed value.

Bailout is also possible with function updates. Here's an example to demonstrate this:

const Component = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(
      () => setCount((c) => c + 1),
      1000,
    );
    return () => clearInterval(id);
  }, []);
  return (
    <div>
      {count}
      <button
        onClick={() =>
          setCount((c) => c % 2 === 0 ? c : c + 1)}
      >
        Increment Count if it makes the result even
      </button>
    </div>
  );
};

If the update function returns the exact same state as the previous state, it will bail out, and this component won't re-render. For example, if you invoke setCount((c) => c), it will never re-render.

Lazy initialization

useState can receive a function for initialization that will be evaluated only in the first render. We can do something like this:

const init = () => 0;
const Component = () => {
  const [count, setCount] = useState(init);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};

The use of init in this example is not very effective because returning 0 doesn't require much computation, but the point is that the init function can include heavy computation and is only invoked to get the initial state. The init function is evaluated lazily, not evaluated before calling useState; in other words, it's invoked just once on mount.

We have now learned how to use useState; next up is useReducer.

 

Using useReducer

In this section, we will learn how to use useReducer. We will learn about its typical usage, how to bail out, its usage with primitive values, and lazy initialization.

Typical usage

A reducer is helpful for complex states. Here's a simple example a with two-property object:

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'SET_TEXT':
      return { ...state, text: action.text };
    default:
      throw new Error('unknown action type');
  }
};
const Component = () => {
  const [state, dispatch] = useReducer(
    reducer,
    { count: 0, text: 'hi' },
  );
  return (
    <div>
      {state.count}
      <button
        onClick={() => dispatch({ type: 'INCREMENT' })}
      >
        Increment count
      </button>
      <input
        value={state.text}
        onChange={(e) =>
          dispatch({ type: 'SET_TEXT', text: e.target.value })}
      />
    </div>
  );
};

useReducer allows us to define a reducer function in advance by taking the defined reducer function and initial state in parameters. The benefit of defining a reducer function outside the hook is being able to separate code and testability. Because the reducer function is a pure function, it's easier to test its behavior.

Bailout

As well as useState, bailout works with useReducer too. Using the previous example, let's modify the reducer so that it will bail out if action.text is empty, as follows:

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'SET_TEXT':
      if (!action.text) {
        // bail out
        return state
      }
      return { ...state, text: action.text };
    default:
      throw new Error('unknown action type');
  }
};

Notice that returning state itself is important. If you return { ...state, text: action.text || state.text } instead, it won't bail out because it's creating a new object.

Primitive value

useReducer works for non-object values, which are primitive values such as numbers and strings. useReducer with primitive values is still useful as we can define complex reducer logic outside it.

Here is a reducer example with a single number:

const reducer = (count, delta) => {
  if (delta < 0) {
    throw new Error('delta cannot be negative');
  }
  if (delta > 10) {
    // too big, just ignore
    return count
  }
  if (count < 100) {
    // add bonus
    return count + delta + 10
  }
  return count + delta
}

Notice that the action (= delta) doesn't have to have an object either. In this reducer example, the state value is a number—a primitive value—but the logic is a little more complex, with more conditions than just adding numbers.

Lazy initialization (init)

useReducer requires two parameters. The first is a reducer function and the second is an initial state. useReducer accepts an optional third parameter, which is called init, for lazy initialization.

For example, useReducer can be used like this:

const init = (count) => ({ count, text: 'hi' });
const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'SET_TEXT':
      return { ...state, text: action.text };
    default:
      throw new Error('unknown action type');
  }
};
const Component = () => {
  const [state, dispatch] = useReducer(reducer, 0, init);
  return (
    <div>
      {state.count}
      <button
        onClick={() => dispatch({ type: 'INCREMENT' })}
      >
        Increment count
      </button>
      <input
        value={state.text}
        onChange={(e) => dispatch({ 
          type: 'SET_TEXT', 
          text: e.target.value,
        })}
      />
    </div>
  );
};

The init function is invoked just once on mount, so it can include heavy computation. Unlike useState, the init function takes a second argument—initialArg—in useReducer, which is 0 in the previous example.

Now we have looked at useState and useReducer separately, it's time to compare them.

 

Exploring the similarities and differences between useState and useReducer

In this section, we demonstrate some similarities and differences between useState and useReducer.

Implementing useState with useReducer

Implementing useState with useReducer instead is 100% possible. Actually, it's known that useState is implemented with useReducer inside React.

Important Note

This may not hold in the future as useState could be implemented more efficiently.

The following example shows how to implement useState with useReducer:

const useState = (initialState) => {
  const [state, dispatch] = useReducer(
    (prev, action) =>
      typeof action === 'function' ? action(prev) : action,
    initialState
  );
  return [state, dispatch];
};

This can then be simplified and improved upon, as follows:

const reducer = (prev, action) =>
  typeof action === 'function' ? action(prev): prev;
const useState = (initialState) =>
  useReducer(reducer, initialState);

Here, we proved that what you can do with useState can be done with useReducer. So, wherever you have useState, you can just replace it with useReducer.

Implementing useReducer with useState

Now, let's explore if the opposite is possible—can we replace all instances of useReducer with useState? Surprisingly, it's almost true. "Almost" means there are subtle differences. But in general, people expect useReducer to be more flexible than useState, so let's see if useState is flexible enough in reality.

The following example illustrates how to implement the basic capability of useReducer with useState:

const useReducer = (reducer, initialState) => {
  const [state, setState] = useState(initialState);
  const dispatch = (action) =>
    setState(prev => reducer(prev, action));
  return [state, dispatch];
};

In addition to this basic capability, we can implement lazy initialization too. Let's also use useCallback to have a stable dispatch function, as follows:

const useReducer = (reducer, initialArg, init) => {
  const [state, setState] = useState(
    init ? () => init(initialArg) : initialArg,
  );
  const dispatch = useCallback(
    (action) => setState(prev => reducer(prev, action)),
    [reducer]
  );
  return [state, dispatch];
};

This implementation works almost perfectly as a replacement for useReducer. Your use case of useReducer is very likely handled by this implementation.

However, we have two subtle differences. As they are subtle, we don't usually consider them in too much detail. Let's learn about them in the following two subsections to get a deeper understanding.

Using the init function

One difference is that we can define reducer and init outside hooks or components. This is only possible with useReducer and not with useState.

Here is a simple count example:

const init = (count) => ({ count });
const reducer = (prev, delta) => prev + delta;
const ComponentWithUseReducer = ({ initialCount }) => {
  const [state, dispatch] = useReducer(
    reducer,
    initialCount,
    init
  );
  return (
    <div>
      {state}
      <button onClick={() => dispatch(1)}>+1</button>
    </div>
  );
};
const ComponentWithUseState = ({ initialCount }) => {
  const [state, setState] = useState(() => 
    init(initialCount));
  const dispatch = (delta) =>
    setState((prev) => reducer(prev, delta));
  return [state, dispatch];
};

As you can see in ComponentWithUseState, useState requires two inline functions, whereas ComponentWithUseReducer has no inline functions. This is a trivial thing, but some interpreters or compilers can optimize better without inline functions.

Using inline reducers

The inline reducer function can depend on outside variables. This is only possible with useReducer and not with useState. This is a special capability of useReducer.

Important Note

This capability is not usually used and not recommended unless it's really necessary.

Hence, the following code is technically possible:

const useScore = (bonus) =>
  useReducer((prev, delta) => prev + delta + bonus, 0);

This works correctly even when bonus and delta are both updated.

With the useState emulation, this doesn't work correctly. It would use an old bonus value in a previous render. This is because useReducer invokes the reducer function in the render phase.

As noted, this is not typically used, so overall, if we ignore this special behavior, we can say useReducer and useState are basically the same and interchangeable. You could just pick either one, based on your preference or your programming style.

 

Summary

In this chapter, we discussed state management and defined micro state management, in which React hooks play an important role. To prepare for the following chapters, we learned about some React hooks that are used for state management solutions, including useState and useReducer, while also looking at their similarities and differences.

In the next chapter, we learn more about a global state. For this purpose, we will discuss a local state and when a local state works, and we will then look at when a global state is required.

About the Author

  • Daishi Kato

    Daishi Kato is a software engineer who is passionate about open-source software. He had been a researcher on peer-to-peer networks and web technologies for decades. His interest has been in engineering, and he has been working with startups for the last 5 years. He has been actively involved in OSS since the ’90s, and his latest work focuses on developing various libraries with JavaScript and React.

    Browse publications by this author
Micro State Management with React Hooks
Unlock this book and the full library FREE for 7 days
Start now