Complexity is the enemy of software.
[— A wise developer 😎]
We’ve all written that component. The one that starts simple but slowly grows into a monster. It’s usually a component that has to manage a process with multiple steps, like a file uploader, a multi-step form, or a data-fetching component with complex caching.
It starts with a simple isLoading
flag. Then you add isError
. Then isSuccess
. Before you know it, you have a tangled web of useState
hooks and a render method that looks like this:
function MessyUploader() {
const [isIdle, setIsIdle] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
const [error, setError] = useState(null);
// ... logic to set these flags ...
return (
<div>
{isIdle && <p>Please select a file.</p>}
{isUploading && <p>Uploading...</p>}
{isSuccess && <p>Success!</p>}
{isError && <p>Error: {error.message}</p>}
{/* ... what if isUploading and isError are both true? ... */}
</div>
);
}
This approach is a breeding ground for bugs. What happens if, due to a race condition, both isUploading
and isError
become true
? You end up showing a confusing UI to the user. These are impossible states, and they are a direct result of managing state with a loose collection of boolean flags.
The Root of the Problem: Implicit State Management
The problem is that the state of our component is implicit. The actual state is spread across multiple boolean variables, and we, the developers, have to mentally piece it together. We have to write defensive code to prevent the component from entering an invalid state.
There has to be a better way. And there is. It’s a concept that’s been around for decades in computer science: the Finite State Machine.
Introducing the State Machine
A Finite State Machine (FSM) is a model of computation that can be in exactly one of a finite number of states at any given time. It can only change from one state to another in response to specific events.
That’s it. It’s a simple but incredibly powerful concept. By making the state of our component explicit, we can eliminate impossible states entirely.
Let's model our file uploader with a state machine. It can be in one of these four states: idle
, uploading
, success
, or error
.
Here’s a simple visualization of the flow:
[ UPLOAD_START ]
(idle) ----------------> (uploading)
| |
[ UPLOAD_SUCCESS ] | | [ UPLOAD_FAILURE ]
v v
(success) (error)
| |
[ RESET ] | | [ RESET ]
+--------+------> (idle)
With this model, it is impossible for the component to be in both the uploading
and success
states at the same time. The logic is clear, predictable, and self-documenting.
Implementing a State Machine in React with useReducer
You don't need a fancy library to implement a state machine. React's built-in useReducer
hook is a perfect tool for the job.
Let's refactor our messy uploader.
import { useReducer } from 'react';
const initialState = {
value: 'idle',
error: null,
};
function uploaderReducer(state, event) {
switch (state.value) {
case 'idle':
if (event.type === 'UPLOAD_START') {
return { ...state, value: 'uploading' };
}
return state;
case 'uploading':
if (event.type === 'UPLOAD_SUCCESS') {
return { ...state, value: 'success' };
}
if (event.type === 'UPLOAD_FAILURE') {
return { ...state, value: 'error', error: event.error };
}
return state;
case 'success':
case 'error':
if (event.type === 'RESET') {
return { ...initialState };
}
return state;
default:
return state;
}
}
function StateMachineUploader() {
const [state, dispatch] = useReducer(uploaderReducer, initialState);
const handleUpload = () => {
dispatch({ type: 'UPLOAD_START' });
// Simulate API call
setTimeout(() => {
if (Math.random() > 0.5) {
dispatch({ type: 'UPLOAD_SUCCESS' });
} else {
dispatch({ type: 'UPLOAD_FAILURE', error: 'Upload failed!' });
}
}, 2000);
};
const handleReset = () => {
dispatch({ type: 'RESET' });
};
return (
<div>
{state.value === 'idle' && (
<button onClick={handleUpload}>Upload File</button>
)}
{state.value === 'uploading' && <p>Uploading...</p>}
{state.value === 'success' && (
<>
<p>Success!</p>
<button onClick={handleReset}>Upload Another</button>
</>
)}
{state.value === 'error' && (
<>
<p>Error: {state.error}</p>
<button onClick={handleReset}>Try Again</button>
</>
)}
</div>
);
}
Look at how much cleaner and more robust this is. The component's render logic is now just a simple switch based on the current state value. All the complex transition logic is centralized and co-located within the reducer. We have successfully tamed the complexity.
The Next Level: XState
For even more complex scenarios, you might consider a dedicated state machine library like XState. It provides a powerful and declarative way to define state machines, and it comes with amazing tools like a visualizer that can automatically generate diagrams from your code.
Conclusion
As senior engineers, our job isn't just to make things work; it's to build systems that are resilient, maintainable, and easy to reason about. State machines are a powerful tool in our arsenal. They force us to think through all the possible states and transitions of our UI, leading to more robust and bug-free components.
The next time you find yourself reaching for another useState
boolean flag, take a moment to consider if a state machine might be a better fit. It might feel like a bit more work upfront, but it will pay for itself tenfold in the long run.