tech

useState, useReducer and State machines for state management

Implement a form using useState, useReducer, and state machines with XState. Understand the differences and tradeoffs of state machines for state management.


Sandro Maglione

Sandro Maglione

Software development

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 for Context values
  • initialContext
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 and useReducer, 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 triggering update-username and update-age, since these events are captured inside Idle.

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 execute
  • input: 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 👈

👋・Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.