State management
React Software Development
May 30, 2023 - Dylan C.

Diving Deeper into State Management in React: Hooks to Know

In the last blog post, we introduced the general ideas behind Micro State Management with React Hooks. In this post we’ll be rolling up our sleeves and digging into some code as we further explore state in more detail. As we’ll be leaning heavily on both useState & useReducer in the future, we’ll explore these fundamental building blocks of state in some detail.

Why Two Hooks for Defining State in React?

Well, each hook, useState & useReducer has its own use case. For simple state, useState is a better option as it is more to the point and cleaner. But we’ll want to reach for useReducer when we have either complex state or complex logic that we have to wrangle.

Let’s say we have several pieces of state that are closely related and we’re using multiple useState calls to handle it all. This can get messy quick, and be a burden to maintain, always having to make sure you update all the different pieces of state. Never fear, useReducer can help neatly package all the related state, allowing us to make sure all the right bits get updated and that our code is decluttered at the same time. Let’s take a look at some code.

How to Use useState

useState takes one argument, the initialState, and returns a tuple of our state and a state setter. The state setter that’s returned can take either a value for the new state or a function that handles the state update.

Here we’re passing in the new state:


    const [count, setCount] = useState(0); 

    //... 

    return (<button onClick={() => setCount(2)}>set to 2</button>); 

We can also pass in a function that gives us the previous state as an argument. This is preferred whenever your new state relies on your old state.


    const [count, setCount] = useState(0); 

    //... 

    return (<button onClick={() => setCount((prevCount) => prevCount + 1)}>increment</button>);     

How to Use useReducer

useReducer takes two to three arguments. The first two, a reducer function, and the initial state are required. The third argument, a function to lazily initialize our state, is optional and we’ll cover it more a little later. To get things started off, here is the function signature of the simpler form of useReducer with the arguments of a useReducer function & the initial state:


    const useReducer = (reducer, initialState) => { /* … */} 

    const reducer = (state, action) => { /* ... */ } 

What Does useReducer Do?

useReducer really shines when we’re dealing with more complex state. For instance, let’s say we’re tracking all the data of our favorite sportsball team and we don’t want to litter our code with a half dozen useState calls. useReducer to the rescue:


    const reducer = (state, action) => { 

      switch (action.type) { 

        case "INCREMENT_WINS": 

          return { ...state, wins: state.wins + 1 }; 

        case "INCREMENT_LOSES": 

          return { ...state, wins: state.wins + 1 };           

        case "ADD_TEAM_MEMBER": 

          return { ...state, members: [...state.members, action.member] }; 

        case "REMOVE_TEAM_MEMBER": 

          return { ...state, members: [...state.members.filter(m => m !== action.member)] }; 

        case "SET_COACH": 

          return { ...state, coach: action.coach }; 

        default: 

          throw new Error("unknown action type"); 

      } 

    }; 

    const [state, dispatch] = useReducer(reducer, { wins: 0, losses: 0, members: [], coach: '', }); 

useReducer also excels if the logic to determine the state gets complex. In this case we’ll purposely stick with a primitive value to highlight that our state is simple even though the logic to set it is anything but.

We’ll change things up for this example and use a glass of water as our state and add a few cases with dealing how to fill it:


    const maxWater = 100; 

    const reducer = (currentWater, newWater) => { 

      if (newWater < 0) { 

        throw new Error("You must at least add some water"); 

      } 

      if (currentWater + newWater > maxWater) { // glass would overflow 

        return maxWater;  

      } 

      return currentWater + newWater; 

    }; 

Lazy Initialization for useState and useReducer

Lazy initialization is not often used but it can be real handy when you do use it. If the initial state is calculated with an expensive call, we can lazily initialize the state so that that function is only called once.

For useState, we would pass in a callback which handles the lazy initialization for us:


  const [state, setState] = useState(() => { 

    const result = expensiveCalculation(props); 

    return result; 

  }); 

It takes a few more steps to handle lazy initialization in useReducer. We’ll add the optional third argument, init and init’s callback will take the second parameter, initial state, as its initialArg argument. We’ll simply wrap the string value in an object here:


    const init = (greeting) => ({ greeting }); 

    const [state, dispatch] = useReducer(reducer, 'hiya' , init); 

Bailout for useState and useReducer

Both useState & useReducer support bailing out or skipping the process of updating the state if the state object has not changed. This saves us a re-render and it comes free out of the box with React. That said, the usual caveat of being careful in how we deal with reference values in JavaScript holds true here as well.

If you are making changes to an object, be sure to do it in an immutable way. In other words, opt for spreading in an object property as opposed to directly tacking on the property to make it clear to React that the state changed and that we want to trigger a re-render.

Conversely, we don’t want to unnecessarily trigger a re-render when there are no changes. To prevent this, we can use useMemo() to wrap the object so that the same reference is passed on every render of the component.

Using the Hooks Together

Now that we’ve covered the basics of working with these two vital hooks and discussed their particular use cases, let’s look at implementing one with the other. Is it even possible? Spoiler alert, it is and aside from some negligible differences between the two, either one can be used. It should be noted that React itself uses useReducer to implement useState under the hood.

Here are the two implementations:

useState implemented with useRender


    import { useReducer } from "react"; 

 
 

    const reducer = (prev, action) => 

      typeof action === "function" ? action(prev) : prev; 

    const useState = (initialState) => useReducer(reducer, initialState); 

useReducer implemented with useState


    import { useState, useCallback } from "react"; 

 
 

    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]; 

    }; 

Now that we’ve covered the two main workhorses when it comes to state, we stand ready to embark on the next leg of our state adventure as we look into global vs local state. See you then!

YOU MAY ALSO LIKE

state management
December 15, 2022 - Dylan C.

Exploring State Management in React

adding helpers
August 12, 2022 - Robert C.

Adding the First Helpers in Ruby on Rails