Imagine trying to debug a broken component and finding 15 different useState
Hooks 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 useState
in 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.
useState
Here 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 validationError
validationError
changes, another useEffect
updates isFormValid
isFormValid
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>
React 19 breaks old third-party integrations. Learn why concurrent rendering exposes brittle SDKs and how to rebuild them with resilient modern patterns.
useEffectEvent
: Goodbye to stale closure headachesDiscover why the useEffectEvent Hook is important, how to use it effectively, and how it compares to useRef.
Shadcn CLI 3.0 takes component management to a new level. With namespaced registries, private access, and AI-powered discovery, it’s now faster and smarter to build React UIs.
Zod’s flexibility comes at a cost. This article breaks down why Zod is slower than AOT-compiled validators like Typia, and how to fix it with a build-time optimization that brings production-grade performance.
Hey there, want to help make our blog better?
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