Compare useState, useReducer and XState

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
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>
  );
}
Github Repository

A common critique of XState is its apparent complexity and verbosity:

  • useState: 33 lines of code
  • useReducer: 50 lines of code
  • machine: 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