Managing state at different levels of complexity is hard. Different tools make different trade-offs between readability, complexity and speed of development. The worst part is that as apps get more complex, it's easy to regret choices that were made early on.
This series of articles should help you make the right choice off the bat. The plan is to cover a bunch of state use cases, starting with the simple and graduating to more complexity as we go. We'll see how easy they are to write, and also how they survive changing requirements.
Today, we're starting with modals.
useState
For modals, the key piece of state is whether or not the modal is open. useState
lets us capture that single piece of state pretty succinctly.
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
};
const toggle = () => {
setIsOpen(!isOpen);
};
Highly readable, simple enough, fast to write, bug-proof. For a simple toggle like this, useState
is great.
useReducer
const reducer = (state = { isOpen: false }, action) => {
switch (action.type) {
case 'OPEN':
return {
isOpen: true,
};
case 'CLOSE':
return {
isOpen: false,
};
case 'TOGGLE':
return {
isOpen: !state.isOpen,
};
default:
return state;
}
};
const [state, dispatch] = useReducer(reducer, { isOpen: false });
const open = () => {
dispatch({ type: 'OPEN' });
};
const close = () => {
dispatch({ type: 'CLOSE' });
};
const toggle = () => {
dispatch({ type: 'TOGGLE' });
};
useReducer
gives us a reducer, a powerful centralized spot in our code where we can visualise the changes happening. However, it took us quite a few more lines of code to reach the same result as useState
. For now, I'd say useState
has the edge.
useMachine
useMachine
is a hook from XState, which allows us to use the power of state machines in our code. Let's see how it looks.
const machine = Machine({
id: 'modalMachine',
initial: 'closed',
states: {
closed: {
on: {
OPEN: {
target: 'open',
},
TOGGLE: 'open',
},
},
open: {
on: {
TOGGLE: 'closed',
CLOSE: 'closed',
},
},
},
});
const [state, send] = useMachine(machine);
const open = () => {
send({ type: 'OPEN' });
};
const close = () => {
send({ type: 'CLOSE' });
};
const toggle = () => {
send({ type: 'TOGGLE' });
};
You can see the state machine on the XState visualiser here.
It's remarkably similar in structure to the reducer above. Similar amount of lines, nearly the same event handlers. The state machine takes the edge over the reducer because of being able to easily visualise its logic - that's something the reducer can't match.
However, the useState
implementation still has the edge for me. The simplicity of execution, the elegance. It's hard to see how it could be beaten...
ALERT: REQUIREMENTS CHANGING
Oh no. Requirements have changed. Now, instead of immediately closing, the modal needs to animate out. This means we need to insert a third state, closing
, which we automatically leave after 500ms. Let's see how our implementations hold up.
useState
Refactor 1: Our initial isOpen
boolean won't handle all the states we need it to any more. Let's change it to an enum: closed
, closing
and open
.
Refactor 2: isOpen
is no longer a descriptive variable name, so we need to rename it to modalState
and setModalState
.
Refactor 3: useState
doesn't handle async changes by itself, so we need to bring in useEffect
to run a timeout when the state is in the closing
state. We also need to clear the timeout if the state is no longer closing
.
Refactor 4: We need to change the toggle event handler to add logic to ensure it only triggers on the closed
and open
states. Toggles work great for booleans, but become much harder to manage with enums.
// Refactor 1, 2
const [modalState, setModalState] = useState('closed');
// Refactor 3
useEffect(() => {
if (modalState === 'closing') {
const timeout = setTimeout(() => {
setModalState('closed');
}, 500);
return () => {
clearTimeout(timeout)
}
}
}, [modalState]);
// Refactor 1, 2
const open = () => {
setModalState('open');
};
// Refactor 1, 2
const close = () => {
setModalState('closing');
};
// Refactor 1, 2, 4
const toggle = () => {
if (modalState === 'closed') {
setModalState('open');
} else if (modalState === 'open') {
setModalState('closing');
}
};
Yuck. That was an enormous amount of refactoring to do just to add a simple, single requirement. On code that might be subject to changing requirements, think twice before using useState
.
useReducer
Refactor 1: Same as above - we turn the isOpen
boolean to the same enum.
Refactor 2: Same as above, isOpen
is now improperly named, so we need to change it to status
. This is changed in fewer places than useState
, but there are still some changes to make.
Refactor 3: The same as above, we use useEffect
to manage the timeout. An additional wrinkle is that we need a new action type in the reducer, REPORT_ANIMATION_FINISHED
, to cover this.
** Refactor 4**: The same as above, but instead of the logic being in the event handler, we can actually change the logic inside the reducer. This is a cleaner change, but is still similar in the amount of lines it produces.
// Refactor 1, 2
const reducer = (state = { status: 'closed' }, action) => {
switch (action.type) {
// Refactor 2
case 'OPEN':
return {
status: 'open',
};
// Refactor 2
case 'CLOSE':
return {
status: 'closing',
};
// Refactor 3
case 'REPORT_ANIMATION_FINISHED':
return {
status: 'closed',
};
// Refactor 4
case 'TOGGLE':
switch (state.status) {
case 'closed':
return {
status: 'open',
};
case 'open':
return {
status: 'closing',
};
}
break;
default:
return state;
}
};
// Refactor 1
const [state, dispatch] = useReducer(reducer, { status: 'closed' });
// Refactor 3
useEffect(() => {
if (state.status === 'closing') {
const timeout = setTimeout(() => {
dispatch({ type: 'REPORT_ANIMATION_FINISHED' });
}, 500);
return () => {
clearTimeout(timeout);
};
}
}, [state.status]);
const open = () => {
dispatch({ type: 'OPEN' });
};
const close = () => {
dispatch({ type: 'CLOSE' });
};
const toggle = () => {
dispatch({ type: 'TOGGLE' });
};
This file required the same number of refactors as the useState
implementation. One crucial advantage is that these refactors were mostly located together: most changes occurred inside the reducer, and the event handlers went largely untouched. For me, this gives useReducer
the edge over useState
.
useMachine
Refactor 1: Add a new closing state, which after 500 milliseconds goes to the closed state.
Refactor 2: Changed the targets of the TOGGLE
and CLOSE
actions to point at closing
instead of closed
.
export const machine = Machine({
id: 'modalMachine',
initial: 'closed',
states: {
closed: {
on: {
OPEN: {
target: 'open',
},
TOGGLE: 'open',
},
},
// Refactor 1
closing: {
after: {
500: 'closed',
},
},
open: {
on: {
// Refactor 2
TOGGLE: 'closing',
CLOSE: 'closing',
},
},
},
});
const [state, send] = useMachine(machine);
const open = () => {
send({ type: 'OPEN' });
};
const close = () => {
send({ type: 'CLOSE' });
};
const toggle = () => {
send({ type: 'TOGGLE' });
};
The difference here is stark. A minimal number of refactors, all within the state machine itself. The amount of lines has hardly changed. None of the event handlers changed. AND we have a working visualisation of the new implementation.
Conclusion
Before the requirements changed, useState
was the champion. It's faster, easier to implement, and fairly clear. useReducer
and useMachine
were too verbose, but useMachine
took the edge by being easier to visualise.
But after the requirements changed, useState
hit the floor. It quickly became the worst implementation. It was the hardest to refactor, and its refactors were in the most diverse places. useReducer
was equally hard to refactor, with the same set of changes. useMachine
emerged as the champion, with a minimal diff required to build in new, complex functionality.
So if you're looking to build a modal fast, use useState
. If you want to build it right, use useMachine
.
I'm excited to work on this set of articles - I'm looking forward to tackling the toughest state models out there. What would you like to see covered in the next one? Some ideas:
- Data fetching
- Form state
- Multi-step sequences (checkout flows, signup flows)
Let me know in the comments below, and follow me for the next article!
Top comments (7)
Thank you. Really interesting and amazing article. Exactly what I was looking for.
I would want to see auth flow. For me was the hardest part.
I have in mobile application:
After that onboarding screens can't be seen (user logged in).
When auth check to Firebase is made (when opening app) - while deciding is user logged in or not - user see splash screen.
I abstracted all of this into
useAccount
custom hook. But that was really hard and a lot of code.Would be happy to see how this can look like with XState.
Great article, thanks! Another (major IMHO) argument in favor of
useMachine
overuseState
/useReducer
is that the latter both depend onuseEffect
hooks that are a potential source of race conditions with asynchronous code. See for example:flufd.github.io/avoiding-race-cond...
joel.net/beware-of-reactuseeffect-...
Another big win for XState is that the machines are framework-agnostic. This means you can share the same your state logic across codebases (front/back/etc) and reuse the code in case your technical requirements change (e.g. front-end framework).
Self-documentation is the cherry on the cake.
Thanks for the article Matt. I would be interested to see how State machines can be used to solved Form State and Multi-step sequences.
Its wonderful for multi-step sequences, it let you visualize the flow of application, have used it in current project, where we tend to manage 3 different flows using parallel states in xstate,
Really great, thanks for posting over at the Stately discord [discord.com/invite/xtWgFTgvNV]
Thanks Matt, great article
Though the point of the article still stands, I'd say it's a bit of a blemish is that the state machine version uses sugar API for the setTimeout logic.