React state management can get messy with nested booleans. Learn why switching from useReducer to state machines helps you avoid impossible UI states.
I remember sitting at my desk at 2:00 AM, staring at a login form that somehow managed to show an "Error" message while simultaneously displaying a "Loading" spinner. It was a classic case of tangled boolean flags, and it’s a rite of passage for every frontend dev. If you're tired of debugging race conditions where your component thinks it's both idle and submitting, it’s time to rethink your approach to React state management.
When your UI logic starts feeling like a house of cards, you usually reach for useReducer. It’s a massive step up from useState because it centralizes your logic. But even with reducers, you can still find yourself in "impossible states" if you aren't careful.
A reducer is just a function that takes the current state and an action to return a new state. It’s great for predictability, but it doesn't inherently prevent you from transitioning to an invalid state.
Take a file upload component. You might have isUploading, isError, and isSuccess flags. If you aren't disciplined, you can easily trigger a sequence where isUploading and isError are both true. You’ve built a state machine in your head, but the code doesn't enforce it.
Before we dive deeper, it helps to understand how React state snapshots: A mental model for functional components work. Every render captures a specific version of your state. If your logic relies on multiple independent variables, that snapshot can quickly become inconsistent.
This is where state machines change the game. Instead of managing independent booleans, you define explicit states: IDLE, UPLOADING, SUCCESS, and ERROR. You then define the allowed transitions between them.
A state machine forces you to be intentional. You can’t go from SUCCESS back to UPLOADING unless you explicitly define that transition. This eliminates entire classes of bugs. When I first started using them, I felt like I was writing more boilerplate, but I spent about 30% less time on bug fixes during the following sprint.
If you're using TypeScript, you can leverage TypeScript state machines: Building predictable UI logic with XState to make this transition even safer. It turns your logic into a strict contract that the compiler enforces.
Let’s look at a simplified example of how we handle a fetch request.
The Reducer Approach:
JAVASCRIPTfunction reducer(state, action) { switch (action.type) { case CE9178">'FETCH_START': return { ...state, loading: true, error: null }; case CE9178">'FETCH_SUCCESS': return { ...state, loading: false, data: action.payload }; case CE9178">'FETCH_ERROR': return { ...state, loading: false, error: action.payload }; default: return state; } }
The State Machine Approach:
JAVASCRIPTconst machine = { initial: CE9178">'idle', states: { idle: { on: { FETCH: CE9178">'loading' } }, loading: { on: { SUCCESS: CE9178">'success', ERROR: CE9178">'error' } }, success: { on: { FETCH: CE9178">'loading' } }, error: { on: { FETCH: CE9178">'loading' } } } };
In the reducer, you have to manually reset error to null every time you start a fetch. In the state machine, moving to the loading state implicitly clears your previous context. You stop managing flags and start managing behavior.
Don't go overboard. If your component is a simple counter or a toggle, stick with useState. If you have a form with one or two fields, useReducer is fine.
However, if you find yourself writing useEffect hooks that track three or more variables to determine if a button should be disabled, you’ve outgrown simple hooks. That’s the moment to model your component as a state machine. It’s not about complexity; it’s about clarity.
Remember that React state management: Mapping your Next.js component hierarchy is still the foundation. Before you reach for a state machine library, make sure your state is living as close to the relevant components as possible.
Does using state machines make my bundle size huge?
It depends on the library. XState is powerful but can be heavy. For smaller projects, you can write a "mini-machine" using a simple object map and useReducer without adding a heavy dependency.
Is it overkill for simple forms? Usually, yes. Don't let the "state machine" label intimidate you into over-engineering. If your form logic is straightforward, keep it simple.
Can I mix these patterns? Absolutely. You don't have to refactor your entire app. I often use standard React hooks for local UI state and reserve state machines for complex workflow orchestrations like multi-step wizards or authentication flows.
I’m still experimenting with how much logic I should pull out of the UI layer. Sometimes, moving too much logic into a machine makes it harder for the next developer to see what’s happening at a glance. My advice? Start by identifying the "impossible states" you currently face, and use a machine only when those bugs become a recurring cost.
React form handling doesn't have to be complex. Learn the trade-offs between controlled and uncontrolled components to decide when to sync your UI state.