Lawrence Oputa I'm a full-stack software developer, instructor, and writer. An open-source and Linux enthusiast with a strong blend of simplicity and creativity. In my spare time, I am cheering for Chelsea.

React patterns to avoid common pitfalls in local state management

7 min read 2215

React Patterns to Avoid Common Pitfalls in State Management

Introduction

In the average React application, there are usually three types of states:

  1. Fetching data and storing data from an API. This type of state is usually managed by a general state management library like Redux.
  2. Local states or any client-side state that one or a few nearby components needs.
  3. Global states, or states used across your entire application. I would also add to this states that are too far apart in the component tree from components that need them (e.g., toggling a sidebar by clicking a header button). States like these can be managed with the React Context API.

In this discourse, our focus will be on local state management and the various React patterns we can use to avoid its common pitfalls.

Prerequisites

To get the most out of this article, you should have some knowledge of React and modern JavaScript. With that, we can get started with a brief a refresher on what state is and why it’s so important in a React application.

What is state in React: A brief refresher

In React, a state is a plain JavaScript object that persists information about a component. States are akin to variables declared within a function. But while such variable disappear when the function exits, React persists state variables between renders.

States hold information that affects the render() method; consequently, state changes trigger a render update. React states start with a default value when a component mounts and gets mutated throughout the component lifecycle.

These state updates or mutations are often triggered by user-generated events. There are a number of ways to initialize state in React, but we will work with the useState Hook. Consider the following code:

const [bulb, setBulb] = useState("off")

The snippet above creates a state variable called bulb and initializes it with the argument of the useState Hook.

How the useState Hook works

Now consider the code below:

const [state, setState] = useState(initialState);

The useState function takes only one argument: the initial state. It returns an array with two values: the current state and a function to update it. We store the values returned from useState using array destructuring. This is why we write:

const [something, setSomething] = useState()

Array destructuring is a modern JavaScript feature. Below is a small code demonstration on how it works. You can read more about it here.

We made a custom demo for .
No really. Click here to check it out.

const [firstname, lastname] = ["Lawrence", "Eagles"];

console.log(firstname);
// expected output: "Lawrence"

console.log(lastname);
// expected output: "Eagles"

// A closer case study would be:
const [state, setState] = [ "hello", (currentState) => console.log(currentState) ]
// console.log(state) returns "hello"
// console.log(setState) returns (currentState) => console.log(currentState)

The image below shows a more complete picture of this:

Demonstration of the useState Array Destructuring Flow

Kindly note that unlike in our demonstration, the second value of the array returned from useState updates the state as below:

import "./styles.css";
import React, { useState } from "react";
export default function DemoApp() {
  console.log("useState result", useState("hello World"));
  return (
    <div className="DemoApp">
      <h2>Hello welcome!</h2>
      <p>Open the console to see the result.</p>
    </div>
  );
}

{/* returns ["hello World", function bound dispatchAction()] */}

Play with the code here — simply refresh the page in CodeSandbox and open the console to see the result.

Common pitfalls in React state management, and patterns to avoid them

A this point you should be comfortable with states in React and how the useState Hook works. Thus, going forward, we will focus on common pitfalls in managing local state and patterns to avoid them.

1. The stale state problem

Let’s jump right into this with some code samples:

import React, { useState } from "react";
import "./styles.css";
export default function Example() {
  const [count, setCount] = useState(0);
  const lazyUpdateState = () => {
    setTimeout(() => {
      setCount(count + 1);
    }, 3000);
  };
  return (
    <div>
      <p>
        <strong>You clicked {count} times</strong>
      </p>
      <button onClick={lazyUpdateState}>Show count</button>
    </div>
  );
}

Above is a React functional component with a local state, count, that has an initial value of 0. It has a function, lazyUpdateState, that updates the count state after 3000 milliseconds. It also displays the current state to the user.

Now, when we click on the lazyUpdateState button n number of times, we expect the state to be equal to n after 3000ms right? But that is not so.

Here we have the stale state problem. No matter how many times the button is clicked, count only increases once. To solve this, we have to compute the next state using the previous state. This ensures that all our clicks are captured in the computation of the current state.

React allows us to pass an updater function to useState. This function takes the previous state as an argument and returns an updated state:

useState (prevState => nextState)

From the syntax above, simply modifying our useState Hook to take an updater function would solve this stale state problem. Below is the correct code. Play with the example here.

import React, { useState } from "react";
import "./styles.css";
export default function Example() {
  const [count, setCount] = useState(0);
  const handleAlertClick = () => {
    setTimeout(() => {
      setCount((count) => count + 1);
    }, 3000);
  };
  return (
    <div>
      <p>
        <strong>You clicked {count} times</strong>
      </p>
      <button onClick={handleAlertClick}>Show count</button>
    </div>
  );
}

We are now passing an updater function to useState. This ensures that the state change from each click event is captured and used in computing the current state.

import React, { useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function App() {
  const [count, setCount] = useState(0);
  const [message, setMessage] = useState("");
  const increment = () => {
    setCount(count + 1);
    setMessage(`computed count is ${count}`);
  };
  const decrement = () => {
    setCount(count - 1);
    setMessage(`computed count is ${count}`);
  };
  return (
    <div className="App">
      <h1>Update Count!</h1>
      <p>Count: {count}</p>
      <p>{message}</p>
      <button type="button" onClick={increment}>
        Add
      </button>
      <button type="button" onClick={decrement}>
        Subtract
      </button>
    </div>
  );
}

2. Accessing states in a “sync-like” manner

useState is asynchronous; thus, it does not work well when we want to access states in a synchronous manner. For this, we have to do some tweaking. Consider code sample below:

import React, { useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function App() {
  const [count, setCount] = useState(0);
  const [currentCount, setCurrentCount] = useState("");
  const increment = () => {
    setCount(count + 1);
    setCurrentCount(`computed count is ${count}`);
  };
  const decrement = () => {
    setCount(count - 1);
    setCurrentCount(`computed count is ${count}`);
  };
  return (
    <div className="App">
      <h1>Update Count!</h1>
      <p>Count: {count}</p>
      <p>{currentCount}</p>
      <button type="button" onClick={increment}>
        Add
      </button>
      <button type="button" onClick={decrement}>
        Subtract
      </button>
    </div>
  );
}

The above component is similar to the stale state component we reviewed earlier. The difference here is that this component has multiple state variables: count and currentCount. In this case, the computation of currentCount depends on count, which is an independent state. Consequently, passing useState an updater function would not work.

When we increment or decrement the states, we see that count is always behind currentCount by 1. Play with the example here.

There are a number of patterns we can use to solve this problem. Let’s look at them below.

Using useEffect

By updating the state in a useEffect with both states as dependency, this problem is solved. This one line resolves the problem:

useEffect(() => setCurrentCount(`count is ${count}`), [count, currentCount]);

Play with code here.

While this pattern works, it is not ideal. Dan Abramov of the React core team considers this an anti-pattern, stating:

This is unnecessary. Why add extra work like running an effect when you already know the next value? Instead, the recommended solution is to either use one variable instead of two (since one can be calculated from the other one, it seems), or to calculate next value first and update them both using it together. Or, if you’re ready to make the jump, useReducer helps avoid these pitfalls.

Calculate next state value and update both states

Consider the code below:

 const increment = () => {
    const newCount = count + 1;
    setCount(newCount);
    currentCount(`count is ${newCount}`);
  }
  const decrement = () => {
    const newCount = count - 1;
    setCount(newCount);
    currentCount(`computed count is ${newCount}`);
  }

We can see that we’ve applied Abramov’s suggestion and made a small tweak to our functions. We calculated the next state — which, in this case, is the newCount — and updated both states with it. This is a much cleaner, better solution. Play with the code here.

Complex state management

useState is great for simple state management. However, it is sometimes used for complex state management. Let’s consider some code samples below:

import React, { useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function FavouriteLanguages() {
  const [languages, setLanguages] = useState([]);
  const [newLanguage, setNewLanguage] = useState("");
  const add = (language) => setLanguages([...languages, language]);
  const remove = (index) => {
    setLanguages([...languages.slice(0, index), ...languages.slice(index + 1)]);
  };
  const handleAddClick = () => {
    if (newLanguage === "") {
      return;
    }
    add({ name: newLanguage });
    setNewLanguage("");
  };
  return (
    <React.Fragment>
      <div className="languages">
        {languages.map((language, index) => {
          return <Movie language={language} onRemove={() => remove(index)} />;
        })}
      </div>
      <div className="add-language">
        <input
          type="text"
          value={newLanguage}
          onChange={(event) => setNewLanguage(event.target.value)}
        />
        <button onClick={handleAddClick}>Add language</button>
      </div>
    </React.Fragment>
  );
}

The component above is a part of a simple app to create a list of your favorite programming languages. We see that the state management involves several operations: add and remove. The implementation of this with useState clutters the component.

A better pattern is to extract the complex state management into the useReducer Hook:

import React, { useState, useReducer } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function reducer(state, action) {
  switch (action.type) {
    case "add":
      return [...state, action.item];
    case "remove":
      return [
        ...state.slice(0, action.index),
        ...state.slice(action.index + 1)
      ];
    default:
      throw new Error();
  }
}
function FavouriteLanguages() {
  const [languages, dispatch] = useReducer(reducer, []);
  const [newLanguages, setLanguages] = useState("");
  const handleAddLanguage = () => {
    if (newLanguages === "") {
      return;
    }
    dispatch({ type: "add", item: { name: newLanguages } });
    setLanguages("");
  };

Above is an extract of the useReducer implementation. Play with the full code here.

We can see that the complex state management operations are abstracted into the reducer function. Also, the add and remove actions trigger these operations. This is way cleaner than cluttering our component by using useState.

This is the power of separation of concerns. The component renders the UI and handles changes from events, while the reducer handles state operations.

Another pro tip is to create the reducer as a separate module. This way, it can be reused in other components, thereby keeping our code DRY.

Accessing latest state from an asynchronous callback

Consider the code below:

import React, { useState } from "react";
import "./styles.css";
function App() {
  const [count, setCount] = useState(0);

 const handleAlert = () => {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlert}>
        Show alert
      </button>
    </div>
  );
}

From the above component, when we click the Show alert button, the handleAlert function is called. This pops up the alert box with the current count after 3000ms.

The problem is that count is always stale, which means that if we increase the count before the alert pops up, it still does not show the latest count. Play with the code here.

While this is similar to the stale state problem we reviewed earlier, the solution is very different. Here, we want to be able to hold mutable data that does not trigger a re-render when updated. The recommended way to do this is to use the useRef Hook. It allow us to keep a data, mutate it, and read from it.

Consider the code below:

import React, { useState, useRef } from "react";
import "./styles.css";
export default function Example() {
  const counterRef = useRef(0);
  const [count, setCount] = useState(false);
  const handleIncrement = () => {
    counterRef.current++;
    setCount(!count);
  };
  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + counterRef.current);
    }, 3000);
  }
  return (
    <div>
      <p>You clicked {counterRef.current} times</p>
      <button onClick={handleIncrement}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

With this implementation, we keep the mutable state counterRef in the useRef Hook. The useRef has a current property, which actually stores our state. Thus, we update that property on every click event.

The count was used here to trigger a re-render so that the changes can be captured in the view. An alternate method would be to use the useEffect Hook. Play with the code here.

Conclusion

Thus ends our discourse. I do hope you found it interesting. Be sure to take precautionary measures not to fall into any of the pitfalls we discussed above.

I will leave you with a good rule of thumb, and that is to stick with the rules of Hooks. Deviating from these can lead to countless pitfalls, regardless of the Hook in use.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Lawrence Oputa I'm a full-stack software developer, instructor, and writer. An open-source and Linux enthusiast with a strong blend of simplicity and creativity. In my spare time, I am cheering for Chelsea.

Leave a Reply