We want to implement a simple form. The form collects username
and age
of the user, and then submits a request.
Simple, day to day web development.
But wait! What state management solution should we use? Let's compare useState
, useReducer
, and XState to understand their differences and tradeoffs 🤔
useState
We start by implementing the form using useState
.
We define an interface
for the data to collect:
interface Context {
username: string;
age: number;
}
The form also need an initial context:
export const initialContext: Context = { username: "", age: 26 };
With these in place we can initialize useState
inside a react component:
export default function Page() {
const [context, setContext] = useState<Context>(initialContext);
return (
<form>
<input type="text" value={context.username} />
<input type="number" value={context.age} />
<button type="submit">Confirm</button>
</form>
);
}
Events
With useState
all events are defined as functions inside the component.
We create two functions to update context
:
onUpdateUsername
onUpdateAge
export default function Page() {
const [context, setContext] = useState<Context>(initialContext);
const onUpdateUsername = (value: string) => {
setContext({ ...context, username: value });
};
const onUpdateAge = (value: number) => {
setContext({ ...context, age: value });
};
return (
<form>
<input
type="text"
value={context.username}
onChange={(e) => onUpdateUsername(e.target.value)}
/>
<input
type="number"
value={context.age}
onChange={(e) => onUpdateAge(e.target.valueAsNumber)}
/>
<button type="submit">Confirm</button>
</form>
);
}
Effects
The last step is handling the form submit.
This will perform an async request (effect). With useState
we define another onSubmit
function inside the component:
export default function Page() {
const [context, setContext] = useState<Context>(initialContext);
const onUpdateUsername = (value: string) => {
setContext({ ...context, username: value });
};
const onUpdateAge = (value: number) => {
setContext({ ...context, age: value });
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await new Promise<boolean>((resolve) =>
setTimeout(() => {
resolve(true);
}, 1000)
);
};
return (
<form onSubmit={onSubmit}>
<input
type="text"
value={context.username}
onChange={(e) => onUpdateUsername(e.target.value)}
/>
<input
type="number"
value={context.age}
onChange={(e) => onUpdateAge(e.target.valueAsNumber)}
/>
<button type="submit">Confirm</button>
</form>
);
}
Notice how with useState
all the logic is defined directly inside the component. We are mixing actions and effects all in the same place.
Furthermore, we directly update the state using setContext
.
useReducer
With useReducer
the first step is exactly the same:
interface
forContext
valuesinitialContext
interface Context {
username: string;
age: number;
}
export const initialContext: Context = { username: "", age: 26 };
Events
With useReducer
instead of directly updating the state we define events.
Each event maps to an action that perform some synchronous logic, in this case update the context:
type Event =
| { type: "update-username"; value: string }
| { type: "update-age"; value: number };
The actual logic is defined inside a reducer
function:
- Takes as input the previous context (
Context
) and the triggered event (Event
) - Returns the next state (
Context
)
export const reducer = (context: Context, event: Event): Context => {
if (event.type === "update-username") {
return { ...context, username: event.value };
} else if (event.type === "update-age") {
return {
...context,
age: isNaN(event.value) ? context.age : event.value,
};
}
throw new Error("Unexpected event");
};
Notice how all of this logic is defined outside of the component.
useReducer
allows to separate the logic to update the context from the component.
Inside the component we get the context
and a send
function to trigger events:
export default function Page() {
const [context, send] = useReducer(reducer, initialContext);
return (
<form>
<input
type="text"
value={context.username}
onChange={(e) =>
send({ type: "update-username", value: e.target.value })
}
/>
<input
type="number"
value={context.age}
onChange={(e) =>
send({ type: "update-age", value: e.target.valueAsNumber })
}
/>
<button type="submit">Confirm</button>
</form>
);
}
Effects
useReducer
doesn't allow to execute asynchronous actions (effects).
We need to fallback to the same strategy of useState
by defining effects as functions inside the component:
export default function Page() {
const [context, send] = useReducer(reducer, initialContext);
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await new Promise<boolean>((resolve) =>
setTimeout(() => {
resolve(true);
}, 1000)
);
};
return (
<form onSubmit={onSubmit}>
<input
type="text"
value={context.username}
onChange={(e) =>
send({ type: "update-username", value: e.target.value })
}
/>
<input
type="number"
value={context.age}
onChange={(e) =>
send({ type: "update-age", value: e.target.valueAsNumber })
}
/>
<button type="submit">Confirm</button>
</form>
);
}
Overall useReducer
allows to better organize complex logic. Instead of direct context updates we can now inspect what events are allowed and map them to update the context.
Nonetheless, useReducer
doesn't help with asynchronous actions. These still need to be defined inside the component.
useMachine
With XState the first step is again exactly the same, defining Context
and initial context:
interface Context {
username: string;
age: number;
}
export const initialContext: Context = { username: "", age: 26 };
Events
Similar to useReducer
we define a list of possible events:
type Event =
| { type: "update-username"; value: string }
| { type: "update-age"; value: number };
Differently from both
useState
anduseReducer
, a machine also contains a finite list of states.
For now the machine has a single Idle
state, which is also defined as the initial state (initial
).
With these we can already scaffold the machine:
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
}).createMachine({
context: initialContext,
initial: "Idle",
states: {
Idle: {},
},
});
Instead of using if
/else if
to map events to actions like useReducer
, with XState we define each action separately inside setup
:
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actions: {
onUpdateUsername: assign((_, { value }: { value: string }) => ({
username: value,
})),
onUpdateAge: assign((_, { value }: { value: number }) => ({
age: value,
})),
},
}).createMachine({
context: initialContext,
initial: "Idle",
states: {
Idle: {},
},
});
We then map each event to the corresponding action to execute:
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actions: {
onUpdateUsername: assign((_, { value }: { value: string }) => ({
username: value,
})),
onUpdateAge: assign((_, { value }: { value: number }) => ({
age: value,
})),
},
}).createMachine({
context: initialContext,
initial: "Idle",
states: {
Idle: {
on: {
"update-username": {
actions: { type: "onUpdateUsername", params: ({ event }) => event },
},
"update-age": {
actions: { type: "onUpdateAge", params: ({ event }) => event },
},
},
},
},
});
Notice how only the
Idle
state allows triggeringupdate-username
andupdate-age
, since these events are captured insideIdle
.This pattern allows to prevent triggering events from unintended states (for example when submitting or loading).
With this we can implement the component:
export default function Page() {
const [snapshot, send] = useMachine(machine);
return (
<form>
<input
type="text"
value={snapshot.context.username}
onChange={(e) =>
send({ type: "update-username", value: e.target.value })
}
/>
<input
type="number"
value={snapshot.context.age}
onChange={(e) =>
send({ type: "update-age", value: e.target.valueAsNumber })
}
/>
<button type="submit">Confirm</button>
</form>
);
}
Notice how this component looks similar to
useReducer
(without effects).Both XState and
useReducer
allow to define actions outside of the component (event-driven programming).
export default function Page() {
const [context, send] = useReducer(reducer, initialContext);
return (
<form>
<input
type="text"
value={context.username}
onChange={(e) =>
send({ type: "update-username", value: e.target.value })
}
/>
<input
type="number"
value={context.age}
onChange={(e) =>
send({ type: "update-age", value: e.target.valueAsNumber })
}
/>
<button type="submit">Confirm</button>
</form>
);
}
Effects
The main difference with XState is that also asynchronous actions (effects) are encoded inside the machine, outside the component.
XState uses actors to handle effects. An actor can be derived from a Promise
by using fromPromise
:
const submit = fromPromise<void, { event: React.FormEvent<HTMLFormElement> }>(
async ({ input }) => {
input.event.preventDefault();
await new Promise<boolean>((resolve) =>
setTimeout(() => {
resolve(true);
}, 1000)
);
}
);
Notice how
submit
is separate from the machine.All actors can be implemented in isolation and then invoked inside the machine.
Inside setup
we add submit
as an actor
:
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submit },
actions: {
onUpdateUsername: assign((_, { value }: { value: string }) => ({
username: value,
})),
onUpdateAge: assign((_, { value }: { value: number }) => ({
age: value,
})),
},
});
An actor is invoked just like actions by using events. We define a new submit
event:
type Event =
| { type: "update-username"; value: string }
| { type: "update-age"; value: number }
| { type: "submit"; event: React.FormEvent<HTMLFormElement> };
When the submit
event is triggered we enter a new Submitting
state:
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submit },
actions: {
onUpdateUsername: assign((_, { value }: { value: string }) => ({
username: value,
})),
onUpdateAge: assign((_, { value }: { value: number }) => ({
age: value,
})),
},
}).createMachine({
context: initialContext,
initial: "Idle",
states: {
Idle: {
on: {
"update-username": {
actions: { type: "onUpdateUsername", params: ({ event }) => event },
},
"update-age": {
actions: { type: "onUpdateAge", params: ({ event }) => event },
},
submit: { target: "Submitting" },
},
},
Submitting: {},
},
});
When entering the Submitting
state we want to execute the submit
actor. We do this by using invoke
:
src
: key of the actor to executeinput
: pass the required input to execute the actor (submit event)
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submit },
actions: {
onUpdateUsername: assign((_, { value }: { value: string }) => ({
username: value,
})),
onUpdateAge: assign((_, { value }: { value: number }) => ({
age: value,
})),
},
}).createMachine({
id: "form-machine",
context: initialContext,
initial: "Idle",
states: {
Idle: {
on: {
"update-username": {
actions: { type: "onUpdateUsername", params: ({ event }) => event },
},
"update-age": {
actions: { type: "onUpdateAge", params: ({ event }) => event },
},
submit: { target: "Submitting" },
},
},
Submitting: {
invoke: {
src: "submit",
input: ({ event }) => {
if (event.type === "submit") {
return { event: event.event };
}
throw new Error("Unexpected event");
},
},
},
},
});
Finally, when the actor completes onDone
inside invoke
is triggered. We then transition to the Complete
state:
export const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submit },
actions: {
onUpdateUsername: assign((_, { value }: { value: string }) => ({
username: value,
})),
onUpdateAge: assign((_, { value }: { value: number }) => ({
age: value,
})),
},
}).createMachine({
id: "form-machine",
context: initialContext,
initial: "Idle",
states: {
Idle: {
on: {
"update-username": {
actions: { type: "onUpdateUsername", params: ({ event }) => event },
},
"update-age": {
actions: { type: "onUpdateAge", params: ({ event }) => event },
},
submit: { target: "Submitting" },
},
},
Submitting: {
invoke: {
src: "submit",
input: ({ event }) => {
if (event.type === "submit") {
return { event: event.event };
}
throw new Error("Unexpected event");
},
onDone: { target: "Complete" },
},
},
Complete: {},
},
});
All the logic is defined outside of the component. Inside the component we simply need to trigger the submit event:
export default function Page() {
const [snapshot, send] = useMachine(machine);
return (
<form onSubmit={(event) => send({ type: "submit", event })}>
<input
type="text"
value={snapshot.context.username}
onChange={(e) =>
send({ type: "update-username", value: e.target.value })
}
/>
<input
type="number"
value={snapshot.context.age}
onChange={(e) =>
send({ type: "update-age", value: e.target.valueAsNumber })
}
/>
<button type="submit">Confirm</button>
</form>
);
}
XState completely separates the state management logic from the UI component, effects included.
This allows to have a clear separation of logic and UI and work on them in isolation. The component is only responsible to render the UI based on state
/context
, and send events to the machine.
If you are interested in learning more about XState and state machines you can check out XState: Complete Getting Started Guide 👈