Imagine trying to debug a broken component and finding 15 different useStateHooks scattered throughout the file. The problem isn’t useState, it’s how we use it.
React’s useState provides instant gratification. You put a state in when you need one and update it wherever you want. It’s easy to start with, and it’s beneficial for managing local component state, but additional solutions may be necessary for larger projects.
The issue isn’t technical. David Khourshid, creator of XState, speaking on a recent episode of PodRocket, put it clearly: “The fundamental problem isn’t React itself. React provides the primitives. However, it’s the way we developers use these primitives.”
Overusing useStatein larger React apps can lead to scattered logic, impossible state combinations, tangled updates, performance issues, and components that are difficult to debug. Because it’s difficult to build a React app without some form of state, developers can avoid these traps by prioritizing how the state is modeled over how it’s stored.
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.
useStateHere are some of the (potential) problems with React useState:
useState is easy to grab. It’s familiar and effortless. But it’s a common misconception to consider something easy as something simple. Something is easy when it requires little effort or thought, and simple when it’s free of secondary complications and readily understood.
useState provides instant gratification, but it’s not always simple. Add a variable here, update later, and your component works. However, as your app grows and you start mixing UI and app states, you may end up with bloated components containing up to 12 different use states, all with varying relevance and unclear relationships. It’s easy to start, but hard to scale and harder to maintain.
Consider this example:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");
const [isFormValid, setIsFormValid] = useState(false);
const [validationError, setValidationError] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [count, setCount] = useState(0);
const [submitEnabled, setSubmitEnabled] = useState(false);
const [showValidationMessage, setShowValidationMessage] = useState(false);
const [greeting, setGreeting] = useState("");
You have a form that requires users to enter their first and last names, so you create two state variables, firstName and lastName. Then you think, “I want to display their full name somewhere in the app.” No problem, add another state variable to compute fullName from the user’s input. “My users need to feel welcome, let’s show a greeting message,” another state variable. Need to track if the form is valid? That’s one more state. Want to disable/enable the submit button based on form validity? What do you need? One more state.
What began as two states for user input has quickly ballooned into eleven. Every addition feels easy and “simple.” Want something? Create a state for it. But only two represent actual user input. React useState makes it easy for developers to create variables, but you can end up with a great wall of interdependent state variables.
Developers often use useEffect to hold their wall of state together. One useEffect to sync names with the greeting, another to check validity, another to reset errors, and so on. This creates what Khourshid describes as Rube Goldberg machines.
A Rube Goldberg machine is a contraption designed to intentionally perform simple tasks in an overly complicated way, using chain reactions. That’s exactly what can happen when you coordinate multiple useState Hooks with useEffect:

Each time you update a state, React re-renders the component to show the new value. When you have effects updating one state because another changed, you can trigger a cascading chain of updates. One thing triggers another, which triggers something else, and you can end up with a chain reaction of untraceable state changes where you can’t determine what caused a change in the first place.
Here’s what that looks like:
// The 11 useStates from the previous example
useEffect(() => {
const fullName = `${firstName} ${lastName}`;
setFullName(fullName);
}, [firstName, lastName]);
useEffect(() => {
const errors = [];
if (firstName.trim() === "") {
errors.push("First name is required");
}
if (lastName.trim() === "") {
errors.push("Last name is required");
}
setValidationError(errors);
}, [firstName, lastName]);
useEffect(() => {
const isValid =
validationError.length === 0 && firstName !== "" && lastName !== "";
setIsFormValid(isValid);
}, [validationError, firstName, lastName]);
useEffect(() => {
if (isFormValid) {
setSubmitEnabled(true);
setShowValidationMessage(true);
setError(null);
} else {
setSubmitEnabled(false);
setShowValidationMessage(false);
}
}, [isFormValid]);
useEffect(() => {
if (fullName.trim()) {
setGreeting(`Hello, ${fullName}!`);
} else {
setGreeting("");
}
}, [fullName]);
useEffect(() => {
if (isLoading) {
setError(null);
}
}, [isLoading]);
const handleSubmit = (e) => {
e.preventDefault();
if (isFormValid) {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setCount(count + 1);
alert(`Successfully submitted! Full Name: ${fullName}`);
}, 1000);
}
};
const handleReset = () => {
setFirstName("");
setLastName("");
setFilter("");
setError(null);
};
Six useEffect hooks are coordinating 11 useState variables. Here’s how the Rube Goldberg chain starts:
firstName or lastName trigger the useEffect that updates fullName and the one that sets validationErrorvalidationError changes, another useEffect updates isFormValidisFormValid then triggers another useEffect 4, which updates three different states: submitEnabled, showValidationMessage, and error.fullName updates, the greeting useEffect is triggered.Every update can trigger more than six re-renders just for a basic form with two input fields. Imagine there was another component on the page, like a search bar that displays and cycles through a list of items as the user types, also requiring multiple useState variables. Debugging becomes a pain.
Boolean flags seem harmless, but they can also conflict. For example:
const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [isError, setIsError] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
These combinations don’t always make sense, nothing prevents two flags from being true by mistake. Imagine a loader still spinning, and you get a notification that your message was sent successfully. This is an impossible state, but perfectly logical according to your code.
Developers rely on implicit patterns, knowing that something works but not necessarily why it works. As Khourshid explains in the podcast, “the default approach is I need to update state, so I’m just going to update state. But then the problem becomes: Why did the state update?”
When you directly update state anywhere in your component tree, you lose context or the audit trail. That updated state might come from props, which might come from centralized or decentralized state management, and then you have no idea where it originated from.
As Khourshid notes, you can’t even rely on call stacks to debug these issues, because “things might be async, things might not happen exactly as you intended [and] you can’t just go back on the trace and see which function ended up changing it.”
useState trapsuseState becomes problematic when it’s the default solution to everything about state management. So, it’s not bad; developers may be using useState in ways that create problems for themselves, hence the need for smarter state modeling.
We should be thinking about architecture, that is, understanding our state structure, before choosing tools. Developers need to step out and think about how their state should be modeled in the first place.
One of Khourshid’s key principles is to “first determine where your source of truth is.” Determine where the state actually lives. Is it in the URL, memory, or database? Then, determine what the rules are and what can cause state changes. From there, you can decide if useState is the best option, you need a different React Hook, or maybe a third-party management tool.
You don’t need to have a state for every variable. One of the biggest contributions to the Rube Goldberg problem is when developers store state that can be derived when needed.
From our earlier example, fullName requires useState and useEffect to listen to two other state variables, firstName and lastName, triggering re-renders before it can update its own state.
The solution would be to calculate it during rendering.
const fullName = `${firstName} ${lastName}`.trim();
There’s no need for a separate useState, no useEffect, and no extra re-renders. The fullName will always be in sync with the firstName and lastName.
The same applies to form validity, because it also depends on the same source.
const isFormValid = firstName.trim() !== "" && lastName.trim() !== "";
If you can derive from the existing state, don’t store variables in a new state. At this point, you’ve lost two state variables and two useEffect hooks. And, for this particular example, we can still simplify the component.
Variables like submitEnabled, showValidationMessage, and greeting can also be calculated during rendering, so they don’t necessarily require their own state and useEffect.
const submitEnabled = isFormValid && !isLoading;
const showValidationMessage = isFormValid && validationError.length === 0;
const greeting = fullName ? `Hello, ${fullName}!` : "";
Validation can have state, but can be moved to the handleSubmit function so it runs only when the user tries to submit. That’s one less useEffect. Finally, we can get rid of the useEffect that handles errors by moving it to when loading starts.
After removing unnecessary state variables and Effects, here’s what you’ll have:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [count, setCount] = useState(0);
const [validationError, setValidationError] = useState([]);
const fullName = `${firstName} ${lastName}`.trim();
const isFormValid = firstName.trim() !== "" && lastName.trim() !== "";
const submitEnabled = isFormValid && !isLoading;
const showValidationMessage = isFormValid && validationError.length === 0;
const greeting = fullName ? `Hello, ${fullName}!` : "";
const handleSubmit = (e) => {
e.preventDefault();
const errors = [];
if (firstName.trim() === "") {
errors.push("First name is required");
}
if (lastName.trim() === "") {
errors.push("Last name is required");
}
if (errors.length > 0) {
setValidationError(errors);
return;
}
setValidationError([]);
setError(null);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setCount(count + 1);
alert(`Successfully submitted! Full Name: ${fullName}`);
}, 1000);
};
const handleReset = () => {
setFirstName("");
setLastName("");
setError(null);
setValidationError([]);
};
We’ve managed to shave off five state variables and all the useEffect Hooks by storing state where it’s actually needed. The component functions the same as before, is no longer bloated, has fewer reasons to re-render, performs better, and is easier to understand and debug.
Instead of modeling with multiple boolean flags that have the potential to conflict, consider using discriminated unions and state machines. With both, you get a finite number of possibilities, making impossible states impossible.
Discriminated unions use TypeScript to define a predefined set of mutually exclusive state objects. String enums are the foundation. Instead of multiple booleans, like we saw earlier, where isLoading , isSuccess, and isError can all be true simultaneously, you have only one possible value:
const [status: setStatus] = useState('idle')
Then discriminated unions combine the string enum with the associated data:
type FormState =
| { status: 'idle'}
| { status: 'loading'}
| { status: 'success'; data: SubmissionResult }
| { status: 'idle'};
const [formState, setFormState] = useState<FormState>({status: 'idle'});
Loading and success can’t happen simultaneously, and data will only exist when status is success.
State machines provide a clear visual mapping of what can happen in your application. When users enter a specific state, they should only be able to do the things possible in that state. Instead of waiting for bug reports, you make invalid state transitions impossible from the start.
const formState = {
idle: {SUBMIT: 'loading' },
loading: {SUCCESS: 'success', ERROR: 'error' },
success: {RESET: 'idle' },
error: {RETRY: 'loading', RESET: 'idle'}
};
With state machines, you can define valid states and valid transitions between them. From the code above, you can only get to the loading state from idle. You cannot go directly from one to the other.
With events, you always know why a change happened. You don’t just see that a state has changed; you can trace exactly what caused the change.
As Khourshid explains: “Events give you the reason why things are updated, but also they allow you to change the logic itself. So if you send the same events and have the logic change, and understand that. I know exactly why this data changed. It’s because this event was sent.”
Direct state changes only tell you what changed, but events preserve the intent behind the change. This makes code easier to maintain; even when you refactor, the intent remains clear.
For components with complex state interactions, useReducer provides the event-driven approach that makes every state change traceable. With useReducer, you can combine related state into a single object and make changes explicit with actions.
Here’s how you can rewrite our example with useReducer.
You’d start by creating a single state object outside the component for related states that change together. Instead of separate useState variables for firstName, LastName, and ValidationError, we get:
const initialState = {
firstName: "",
lastName: "",
validationError: [],
};
Next, create the reducer function that takes in the current state and an action.
function formReducer(state, action) {
switch (action.type) {
case "SET_FIRST_NAME":
return {
...state,
firstName: action.payload,
};
case "SET_LAST_NAME":
return {
...state,
lastName: action.payload,
};
case "SET_VALIDATION_ERROR":
return {
...state,
validationError: action.payload,
};
case "RESET_FORM":
return initialState;
default:
return state;
}
}
The entire state, that’s all the update logic, is represented in one single object. Every possible state transition is in one place.
Every update is described by an action.type, the “why” or intent behind the state change. So, SET_FIRST_NAME tells you clearly that the state updates because the user typed in their first name, and not as a result of something else.
Now, inside the component, you add the useReducer hook that takes our formReducer and initialState, and returns the updated state and a dispatch function.
const [state, dispatch] = useReducer(formReducer, initialState);
The rest of the code is largely the same, with minor updates. You still have your derived values, and some useState variables. You’d have to add state. to variable names because, instead of calling setState, you dispatch actions with a type and payload.
const [state, dispatch] = useReducer(formReducer, initialState);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [count, setCount] = useState(0);
const fullName = `${state.firstName} ${state.lastName}`.trim();
const greeting = fullName ? `Hello, ${fullName}!` : "";
const isFormValid =
state.firstName.trim() !== "" && state.lastName.trim() !== "";
const submitEnabled = isFormValid && !isLoading;
const showValidationMessage =
isFormValid && state.validationError.length === 0;
const handleSubmit = (e) => {
e.preventDefault();
const errors = [];
if (state.firstName.trim() === "") {
errors.push("First name is required");
}
if (state.lastName.trim() === "") {
errors.push("Last name is required");
}
if (errors.length > 0) {
dispatch({ type: "SET_VALIDATION_ERROR", payload: errors });
return;
}
dispatch({ type: "SET_VALIDATION_ERROR", payload: [] });
setError(null);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setCount(count + 1);
alert(`Successfully submitted! Full Name: ${fullName}`);
}, 1000);
};
const handleReset = () => {
dispatch({ type: "RESET_FORM" });
setFilter("");
setError(null);
};
The code is cleaner, state changes are traceable, and you only need to look at the action types to understand the “why.”
The useState Hook still has its place. You can use it for a simple, isolated state when there are no complex interactions or rules. However, consider alternatives when you have multiple interdependent states, states that need to be shared across components, or when you need to be able to trace state changes.
Modern applications have multiple sources of truth, so you can’t just force everything into useState. When you model your states before implementation, you’ll know what tools to use for each situation. Ask yourself, what states can this component have? What events can cause state changes? Where should the source of truth be?
The goal is not to sideline or eliminate useState.It’s about using it intentionally as part of a well-designed architecture rather than just the default solution for state management.
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>

AI agents can now log in, act, and access data, but have you truly authorized them? This guide walks through how to secure your autonomous agents using Auth0’s Auth for GenAI, covering token vaults, human-in-the-loop approvals, and fine-grained access control.

A hands-on guide to building an FTC-ready chatbot: real age checks, crisis redirects, parental consent, audit logs, and usage limits – designed to protect minors and prevent harm.

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.
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