Here is the full code for all the three implementations: useState
, useReducer
and XState.
useState
import { useState } from "react";
import { initialContext, postRequest, type Context } from "./shared";
export default function UseState() {
const [context, setContext] = useState<Context>(initialContext);
const [loading, setLoading] = useState(false);
const onUpdateUsername = (value: string) => {
setContext({ username: value });
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!loading) {
setLoading(true);
await postRequest(context);
setLoading(false);
}
};
return (
<form onSubmit={onSubmit}>
<input
type="text"
value={context.username}
onChange={(e) => onUpdateUsername(e.target.value)}
/>
<button type="submit" disabled={loading}>
Confirm
</button>
</form>
);
}
useReducer
import { useReducer } from "react";
import { initialContext, postRequest, type Context } from "./shared";
type Event =
| { type: "update-username"; value: string }
| { type: "update-loading"; value: boolean };
type ReducerContext = Context & {
loading: boolean;
};
const reducer = (context: ReducerContext, event: Event): ReducerContext => {
if (event.type === "update-username") {
return { ...context, username: event.value };
} else if (event.type === "update-loading") {
return { ...context, loading: event.value };
}
return context;
};
export default function UseReducer() {
const [context, dispatch] = useReducer(reducer, {
...initialContext,
loading: false,
});
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!context.loading) {
dispatch({ type: "update-loading", value: true });
await postRequest(context);
dispatch({ type: "update-loading", value: false });
}
};
return (
<form onSubmit={onSubmit}>
<input
type="text"
value={context.username}
onChange={(e) =>
dispatch({ type: "update-username", value: e.target.value })
}
/>
<button type="submit" disabled={context.loading}>
Confirm
</button>
</form>
);
}
Machine
Github Repository
import { useActor } from "@xstate/react";
import { assign, fromPromise, setup } from "xstate";
import { initialContext, postRequest, type Context } from "./shared";
type Event =
| { type: "update-username"; username: string }
| { type: "submit"; event: React.FormEvent<HTMLFormElement> };
const submitActor = fromPromise(
async ({
input,
}: {
input: { event: React.FormEvent<HTMLFormElement>; context: Context };
}) => {
input.event.preventDefault();
await postRequest(input.context);
}
);
const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submitActor },
}).createMachine({
context: initialContext,
initial: "Editing",
states: {
Editing: {
on: {
"update-username": {
actions: assign(({ event }) => ({
username: event.username,
})),
},
submit: { target: "Loading" },
},
},
Loading: {
invoke: {
src: "submitActor",
input: ({ event, context }) => {
assertEvent(event, "submit");
return { event: event.event, context };
},
onDone: { target: "Complete" },
},
},
Complete: {},
},
});
export default function Machine() {
const [snapshot, send] = useActor(machine);
return (
<form onSubmit={(event) => send({ type: "submit", event })}>
<input
type="text"
value={snapshot.context.username}
onChange={(e) =>
send({ type: "update-username", username: e.target.value })
}
/>
<button type="submit" disabled={snapshot.matches("Loading")}>
Confirm
</button>
</form>
);
}
A common critique of XState is its apparent complexity and verbosity:
useState
: 33 lines of codeuseReducer
: 50 lines of codemachine
: 71 lines of code
However, the number of lines don't tell the whole story.
XState organizes the logic in separate independent actors. It also makes each state explicit. This causes the code to grown in size, but it brings many other benefits:
- Easier to maintain each actor, since they are mostly independent
- Easier to test each actor in isolation
- Easier to implement complex logic by composing smaller actors
- Easier to reason about the state of the machine (no need of separate
useState
for each state) - All the logic is in one place and separate from the component
Furthermore, extending the logic of XState actors becomes way easier. Let's see how all these benefits manifest by adding some features:
- Manages possible errors in the async request
- Provide a confirmation message when the request is successful