The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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.
useEffectBy 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,
useReducerhelps 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>

CSS text-wrap: balance vs. text-wrap: prettyCompare and contrast two CSS components, text-wrap: balance and text-wrap: pretty, and discuss their benefits for better UX.

Remix 3 ditches React for a Preact fork and a “Web-First” model. Here’s what it means for React developers — and why it’s controversial.

A quick guide to agentic AI. Compare Autogen and Crew AI to build autonomous, tool-using multi-agent systems.

Compare the top AI development tools and models of November 2025. View updated rankings, feature breakdowns, and find the best fit for you.
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 now