System design
React Added Redux. It’s Called useReducer.
I once spent four hours debugging a component that kept fetching data in an infinite loop. The culprit? An object dependency that was being recreated on every render. The fix was adding useMemo. The real problem was that nothing about useEffect made this obvious.
It seems like JavaScript has a way of evolving that solves problems while creating a whole host of new ones.
Hooks are a great example. Engineers often thought class components were too verbose for managing state and lifecycle logic. The desire to reuse stateful logic across components without wrapper hell led React’s team to create hooks.
Along with hooks, React moved away from the old behavior for managing component lifecycle. Instead of declarative methods like componentDidMount, most engineers moved to the catch-all useEffect hook without understanding the implications.
useEffect
The useEffect hook suffers from a need for careful and explicit management to avoid its many pitfalls:
- Forgetting a dependency causes a failure to update
- Using objects or functions as dependencies without memoization can cause infinite re-runs, since a new {} or () → {} is created each render and the effect sees it as “changed” every time
- Omitting the dependency array entirely means the hook re-runs on every render
- Setting up an interval or event listener that references state captures the value at creation time. The callback won’t see subsequent state changes unless you work around it with refs or functional updaters
- In strict mode, React fires these hooks twice on purpose to surface bugs, which creates a confusing mental model during development
These bugs can be incredibly difficult to debug. On teams with mixed experience levels, ensuring everyone understands hooks and their non-intuitive pitfalls becomes an absolute nightmare.
The Fix
The root issue with useEffect is that state transitions are implicit. Your component’s behavior emerges from the interaction of multiple effects watching multiple pieces of state, and nothing enforces consistency. Declarative state via a state machine flips this model by making transitions explicit and impossible states unrepresentable.
With useEffect, you cannot simultaneously maintain a single source of truth for state and follow the single responsibility principle, creating a maintainability conundrum that burns engineering hours on “optimizations” which is a special kind of hell for engineering teams.
XState is well positioned to solve these problems. A state machine requires you to define explicit states your component can be in and what causes transitions between them. Instead of scattered effects reacting to changes implicitly, you model the logic upfront.
While XState does require some setup, it pays off when you have multiple frontend maintainers or anything with intermediate complexity like authentication flows, multi-step forms, or async data fetching with retry logic.
This isn’t to say every component needs a state machine. A simple toggle or single API call doesn’t warrant the overhead. But once you have three or more interrelated pieces of state, the upfront investment starts making sense.
Consider an authentication flow. With useEffect, you might end up with something like:
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [user, setUser] = useState(null);
const [isRetrying, setIsRetrying] = useState(false);
useEffect(() => {
if (isRetrying) {
setIsLoading(true);
setError(null);
}
}, [isRetrying]);
useEffect(() => {
if (isLoading) {
login(credentials)
.then(setUser)
.catch(setError)
.finally(() => setIsLoading(false));
}
}, [isLoading]);
Four pieces of state, multiple effects watching each other, and nothing preventing impossible combinations like isLoading: true and user: {…} existing simultaneously.
With XState, you define what states are actually possible:
const authMachine = createMachine({
initial: 'idle',
states: {
idle: { on: { LOGIN: 'loading' } },
loading: {
invoke: {
src: 'loginService',
onDone: { target: 'success', actions: 'assignUser' },
onError: { target: 'error', actions: 'assignError' }
}
},
error: { on: { RETRY: 'loading' } },
success: { type: 'final' }
}
})
The component can only be in one state at a time. There’s no way to be loading and successful simultaneously. The transitions are explicit and the flow is readable at a glance.
In my own engineering, I’ve noticed that intermediate to complex pages I migrated to XState meant less time hunting down problematic state bugs. Even more valuable: defining the state machine and thinking through transitions explicitly surfaced flow issues that would have otherwise gone unnoticed, leading to a better user experience.
Where to Go from Here
If you’re dealing with useEffect spaghetti in your codebase, start small. Pick one component with interrelated state that’s been a source of bugs and try modeling it as a state machine. XState’s documentation and visualizer make it easy to experiment. You might find, like I did, that the bugs you were chasing were symptoms of a modeling problem that no amount of careful dependency management would have solved.