In the average React application, there are usually three types of states:
In this discourse, our focus will be on local state management and the various React patterns we can use to avoid its common pitfalls.
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.
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.
useState
Hook worksNow 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.
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:
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.
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.
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> ); }
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.
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]);
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.
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.
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.
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.
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]