tech

State machines will save you from useState

useState is easy to use and learn. But it's definitely not ideal for complex state management. This is how state machines and XState will save your project.


Sandro Maglione

Sandro Maglione

Software development

New project this sprint! Users want to be notified about their activity. The product team came up with a prototype idea for a notifications section. It's finally time to write some code!

The requirements arrive just in time:

Implement a mock for a notification panel that is shown after the user clicks a button

Simple! So simple in fact that useState works well enough (no over-engineering here):

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([
    { id: "1", message: "It's time to work" },
    { id: "2", message: "Up for a meeting?" },
  ]);
  const [open, setOpen] = useState(false);
  return (
    <div>
      {!open ? (
        <button onClick={() => setOpen(true)}>Open notifications</button>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Done! Frontend development is so easy! Opened PR for review.

After a short while we get an update:

This will need to fetch the notifications from an endpoint, we cannot hardcode them. Requires review!

Indeed. Well, back to work!

This is simply about adding a function to request the data. A simple Promise, some async/await, and it works:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);

  const onLoadNotification = async () => {
    const response = await new Promise<Notification[]>((resolve) => {
      setTimeout(() => {
        resolve([
          { id: "1", message: "It's time to work" },
          { id: "2", message: "Up for a meeting?" },
        ]);
      }, 1200);
    });

    setOpen(true);
    setNotifications(response);
  };

  return (
    <div>
      {!open ? (
        <button onClick={onLoadNotification}>Open notifications</button>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Done for today!

No wait. Something isn't working. When you click "Open notifications" nothing happens. Then, after a while, the notifications appear.

Ah, right! setOpen(true) is called after awaiting the Promise, so until then the UI keeps showing the button.

We need a loading indicator of course! That's a new state I guess:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);

  const onLoadNotification = async () => {
    setLoading(true);
    const response = await new Promise<Notification[]>((resolve) => {
      setTimeout(() => {
        resolve([
          { id: "1", message: "It's time to work" },
          { id: "2", message: "Up for a meeting?" },
        ]);
      }, 1200);
    });

    setOpen(true);
    setNotifications(response);
  };

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          <button onClick={onLoadNotification}>Open notifications</button>
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

It starts to look more complex. Well, refactoring later, no early optimization.

Another comment on the PR:

With this clicking multiple times "Open notifications" will send multiple requests.

Yes, I forgot about that. We need a disabled state here. Well, just to be safe let's also add a check before sending the request:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);

  const onLoadNotification = async () => {
    if (!loading) {
      setLoading(true);
      const response = await new Promise<Notification[]>((resolve) => {
        setTimeout(() => {
          resolve([
            { id: "1", message: "It's time to work" },
            { id: "2", message: "Up for a meeting?" },
          ]);
        }, 1200);
      });

      setOpen(true);
      setNotifications(response);
    }
  };

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          <button disabled={loading} onClick={onLoadNotification}>
            Open notifications
          </button>
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Wait. I forgot to set loading back to false. That's an easy fix:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);

  const onLoadNotification = async () => {
    if (!loading) {
      setLoading(true);
      const response = await new Promise<Notification[]>((resolve) => {
        setTimeout(() => {
          resolve([
            { id: "1", message: "It's time to work" },
            { id: "2", message: "Up for a meeting?" },
          ]);
        }, 1200);
      });

      setOpen(true);
      setNotifications(response);
      setLoading(false);
    }
  };

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          <button disabled={loading} onClick={onLoadNotification}>
            Open notifications
          </button>
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Soon after a new requirement from product:

The request may fail, we want to show an error message to the user if something wrong happens.

An error message means a new state. Let's add also try/catch to prevent errors:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const onLoadNotification = async () => {
    if (!loading) {
      try {
        setLoading(true);
        const response = await new Promise<Notification[]>((resolve) => {
          setTimeout(() => {
            resolve([
              { id: "1", message: "It's time to work" },
              { id: "2", message: "Up for a meeting?" },
            ]);
          }, 1200);
        });

        setOpen(true);
        setNotifications(response);
        setLoading(false);
      } catch (e) {
        setError("Error while loading notifications");
      }
    }
  };

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          {error.length > 0 && <span>{error}</span>}
          <button disabled={loading} onClick={onLoadNotification}>
            Open notifications
          </button>
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

While testing this something odd happens. The "Loading..." label never disappears.

After some annoying debugging I find the problem: setLoading(false) is called only after await. If the request fails loading is never set to false.

Someone on StackOverflow says to use the finally block, let's do this:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const onLoadNotification = async () => {
    if (!loading) {
      try {
        setLoading(true);
        const response = await new Promise<Notification[]>((resolve) => {
          setTimeout(() => {
            resolve([
              { id: "1", message: "It's time to work" },
              { id: "2", message: "Up for a meeting?" },
            ]);
          }, 1200);
        });

        setOpen(true);
        setNotifications(response);
      } catch (e) {
        setError("Error while loading notifications");
      } finally {
        setLoading(false);
      }
    }
  };

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          {error.length > 0 && <span>{error}</span>}
          <button disabled={loading} onClick={onLoadNotification}>
            Open notifications
          </button>
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

I just tested this. The first request failed and showed the error. But then I clicked "Open notifications" again, it worked, I see the notifications, but the error is still there.

Right. Same issue as before, we need to reset error on each request:

import { useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const onLoadNotification = async () => {
    if (!loading) {
      try {
        setError("");
        setLoading(true);
        const response = await new Promise<Notification[]>((resolve) => {
          setTimeout(() => {
            resolve([
              { id: "1", message: "It's time to work" },
              { id: "2", message: "Up for a meeting?" },
            ]);
          }, 1200);
        });

        setOpen(true);
        setNotifications(response);
      } catch (e) {
        setError("Error while loading notifications");
      } finally {
        setLoading(false);
      }
    }
  };

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          {error.length > 0 && <span>{error}</span>}
          <button disabled={loading} onClick={onLoadNotification}>
            Open notifications
          </button>
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Now it really looks complex. No time to refactor for now. We need to ship here! Ship it!

After a few days new requirement:

We want to load the notification right away, without the user clicking any button. The panel should be closed until everything is loaded.

That's going to be interesting. "Loading right away" sounds like a useEffect:

import { useEffect, useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const onLoadNotification = async () => {
    if (!loading) {
      try {
        setError("");
        setLoading(true);
        const response = await new Promise<Notification[]>((resolve) => {
          setTimeout(() => {
            resolve([
              { id: "1", message: "It's time to work" },
              { id: "2", message: "Up for a meeting?" },
            ]);
          }, 1200);
        });

        setOpen(true);
        setNotifications(response);
      } catch (e) {
        setError("Error while loading notifications");
      } finally {
        setLoading(false);
      }
    }
  };

  useEffect(() => {
    onLoadNotification();
  }, []);

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          {error.length > 0 && <span>{error}</span>}
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

This looks good, and it seems to be working!

A short while later someone noticed a problem: when the request fails it's not possible to retry loading the notifications.

Let's add back a button:

import { useEffect, useState } from "react";

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const onLoadNotification = async () => {
    if (!loading) {
      try {
        setError("");
        setLoading(true);
        const response = await new Promise<Notification[]>((resolve) => {
          setTimeout(() => {
            resolve([
              { id: "1", message: "It's time to work" },
              { id: "2", message: "Up for a meeting?" },
            ]);
          }, 1200);
        });

        setOpen(true);
        setNotifications(response);
      } catch (e) {
        setError("Error while loading notifications");
      } finally {
        setLoading(false);
      }
    }
  };

  useEffect(() => {
    onLoadNotification();
  }, []);

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          {error.length > 0 && (
            <>
              <span>{error}</span>
              <button onClick={onLoadNotification}>Reload</button>
            </>
          )}
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

But wait! It doesn't look correct to retry for all errors. If the server is down (error 500), then don't show the reload button.

Yet another useState:

import { useEffect, useState } from "react";

class ApiError {
  status: number;
  constructor(status: number) {
    this.status = status;
  }
}

interface Notification {
  id: string;
  message: string;
}

export default function Page() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [retry, setRetry] = useState(true);

  const onLoadNotification = async () => {
    if (!loading) {
      try {
        setError("");
        setLoading(true);
        const response = await new Promise<Notification[]>((resolve) => {
          setTimeout(() => {
            resolve([
              { id: "1", message: "It's time to work" },
              { id: "2", message: "Up for a meeting?" },
            ]);
          }, 1200);
        });

        setOpen(true);
        setNotifications(response);
      } catch (e) {
        if (e instanceof ApiError && e.status >= 500) {
          setRetry(false);
          setError("Error while loading notifications");
        } else {
          setError("Error while loading notifications, please retry");
        }
      } finally {
        setLoading(false);
      }
    }
  };

  useEffect(() => {
    onLoadNotification();
  }, []);

  return (
    <div>
      {!open ? (
        <>
          {loading && <span>Loading...</span>}
          {error.length > 0 && (
            <>
              <span>{error}</span>
              {retry && <button onClick={onLoadNotification}>Reload</button>}
            </>
          )}
        </>
      ) : (
        <>
          {notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Now it really looks complex. Yet, there is no time right now for refactoring.

Let's jump to the next feature. Hopefully it won't be a complex change...


This is the confusion of how a real project evolves, and how useState and useEffect creep in to make code unreadable and unmaintainable.

Obviously (or not) this exaggerates the real implementation story a bit (or does it? Maybe not 👀).

In fact, this is the state of most React codebases. Some common problems include:

  • As the project grows the code becomes hard to understand
  • Logic is implemented in separate places, it's hard to follow how the UI evolves
  • All the logic is implemented inside the component, over time it becomes hard to extract and refactor
  • useState models single values, but it doesn't do a good job with multiple related states
  • It's possible to model impossible states: loading and error cannot be present at the same time. This requires to reset them every time and implement complex checks to verify the current state

There is a better way to model UI state: State Machines.

Let's rewind and start from the beginning, this time using XState and state machines.


How state machines save the day (and the months afterward!)

The requirements arrive just in time:

Implement a mock for a notification panel that is shown after the user clicks a button

The first step with state machines is defining states. This requirement has 2 states:

  • Closed
  • Opened

The second step is choosing the initial state, Closed in this case.

The third step is defining the context. For this initial requirement context contains the notifications.

States in state machines are not the same as values (context).

States are a finite list of "states" allowed in the app (for example opened, closed, loading).

The values that the app contains are instead defined inside context.

This is the final machine:

import { setup } from "xstate";

interface Notification {
  id: string;
  message: string;
}

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[] },
  },
}).createMachine({
  // 👇 Context ("values")
  context: {
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },

  // 👇 Initial state
  initial: "Closed",

  // 👇 Finite list of states ("modes")
  states: {
    Closed: {
      on: {
        fetch: { target: "Opened" },
      },
    },
    Opened: {},
  },
});

This will need to fetch the notifications from an endpoint, we cannot hardcode them. Requires review!

XState uses actors to handle asynchronous effects.

We define actors inside setup/actors. An actor can be created from a Promise using fromPromise:

// 🤖 Actor created from a `Promise`
const getNotifications = fromPromise(
  () =>
    new Promise<Notification[]>((resolve) => {
      setTimeout(() => {
        resolve([
          { id: "1", message: "It's time to work" },
          { id: "2", message: "Up for a meeting?" },
        ]);
      }, 1200);
    })
);

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[] },
  },
  actors: { getNotifications },
});

The logic of how to execute effects is completely separate from the machine and the component.

We can then execute the actor when we enter a state by using invoke:

  • src: actor to execute
  • onDone+target: next state when the promise is successful
export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[] },
  },
  actors: { getNotifications },
}).createMachine({
  context: {
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Closed",
  states: {
    Closed: {
      on: {
        fetch: { target: "Loading" },
      },
    },
    Loading: {
      invoke: { src: "getNotifications", onDone: { target: "Opened" } },
    },
    Opened: {},
  },
});

Updating the context is done by defining an action.

actors are used for async effects.

actions are used for sync updates.

We can add a new action to update the context using assign inside setup/actions:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[] },
  },
  actors: { getNotifications },
  actions: {
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
})

assign is used to update the context. It requires to return the context values to update (in this case notifications):

  • The first parameter contains the current context and other methods (_ in the example)
  • The second parameter is the input required to run the action (in this case the notifications that we get from the getNotifications actor)

Inside onDone we can extract the output returned by the actor from event.output and execute the onUpdateNotifications actions:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[] },
  },
  actors: { getNotifications },
  actions: {
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Closed",
  states: {
    Closed: {
      on: {
        fetch: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "getNotifications",
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
  },
});

We define all the logic and states inside the machine. The component now has only 2 roles:

  • Define how to display the UI based on the current state/context
  • Send events on user interactions
import { useMachine } from "@xstate/react";
import { machine } from "./machine";

export default function Page() {
  const [snapshot, send] = useMachine(machine);
  return (
    <div>
      {snapshot.matches("Closed") ? (
        <button onClick={() => send({ type: "fetch" })}>
          Load notifications
        </button>
      ) : snapshot.matches("Loading") ? (
        <span>Loading...</span>
      ) : (
        <>
          {snapshot.context.notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

Notice how all the issues that we discovered and fixed with useState are not even possible with XState.

Since a state machine has only 1 state active at any point in time we don't need to reset values or manage conflicts.

We want to show an error message to the user if something wrong happens.

For this we add a new errorMessage value to context. When getNotifications fails we can set the errorMessage value.

We first add an action to update errorMessage:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[]; errorMessage: string },
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign({
      errorMessage: "Error while loading notifications",
    }),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Closed",
  states: {
    Closed: {
      on: {
        fetch: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "getNotifications",
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
  },
});

fromPromise catches all errors and provides them inside invoke/onError. We therefore execute onUpdateErrorMessage action if an error occurs and transition to a new Error state:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[]; errorMessage: string },
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign({
      errorMessage: "Error while loading notifications",
    }),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Closed",
  states: {
    Closed: {
      on: {
        fetch: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "getNotifications",
        onError: { target: "Error", actions: { type: "onUpdateErrorMessage" } },
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
    Error: {},
  },
});

In the component we only need to match on the new Error state:

export default function Page() {
  const [snapshot, send] = useMachine(machine);
  return (
    <div>
      {snapshot.matches("Closed") ? (
        <button onClick={() => send({ type: "fetch" })}>
          Load notifications
        </button>
      ) : snapshot.matches("Loading") ? (
        <span>Loading...</span>
      ) : snapshot.matches("Error") ? (
        <span>{snapshot.context.errorMessage}</span>
      ) : (
        <>
          {snapshot.context.notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

We want to load the notification right away, without the user clicking any button. The panel should be closed until everything is loaded.

The only change needed is changing the initial state, from Closed to Loading.

Since Loading already executes invoke, by making it the initial state the request is performed immediately:

  • No need the Closed state anymore, since Loading already represents the "closed" state
  • No need of the fetch event, since the notifications are no more loaded on click

invoke in the initial state implements a request "on mount".

export const machine = setup({
  types: {
    context: {} as { notifications: Notification[]; errorMessage: string },
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign({
      errorMessage: "Error while loading notifications",
    }),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Loading",
  states: {
    Loading: {
      invoke: {
        src: "getNotifications",
        onError: { target: "Error", actions: { type: "onUpdateErrorMessage" } },
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
    Error: {},
  },
});

The component becomes even easier without the Closed state:

export default function Page() {
  const [snapshot] = useMachine(machine);
  return (
    <div>
      {snapshot.matches("Loading") ? (
        <span>Loading...</span>
      ) : snapshot.matches("Error") ? (
        <span>{snapshot.context.errorMessage}</span>
      ) : (
        <>
          {snapshot.context.notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

When the request fails it's not possible to retry loading the notifications.

We add back the fetch event, this time attached to the Error state:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as { notifications: Notification[]; errorMessage: string },
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign({
      errorMessage: "Error while loading notifications",
    }),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Loading",
  states: {
    Loading: {
      invoke: {
        src: "getNotifications",
        onError: { target: "Error", actions: { type: "onUpdateErrorMessage" } },
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
    Error: {
      on: {
        fetch: { target: "Loading" },
      },
    },
  },
});

We add a "Reload" button on the UI to display on the Error state:

export default function Page() {
  const [snapshot, send] = useMachine(machine);
  return (
    <div>
      {snapshot.matches("Loading") ? (
        <span>Loading...</span>
      ) : snapshot.matches("Error") ? (
        <>
          <span>{snapshot.context.errorMessage}</span>
          <button onClick={() => send({ type: "fetch" })}>Reload</button>
        </>
      ) : (
        <>
          {snapshot.context.notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

If the server is down (error 500), then don't show the reload button.

We need to remove the "Reload" button based on the response status. We achieve this by storing the status inside context.

The status is stored in event.error inside invoke/onError. We pass this value to onUpdateErrorMessage action:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as {
      notifications: Notification[];
      errorMessage: string;
      status: number | null;
    },
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign((_, { error }: { error: unknown }) => ({
      status: error instanceof ApiError ? error.status : null,
      errorMessage: "Error while loading notifications",
    })),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    status: null,
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Loading",
  states: {
    Loading: {
      invoke: {
        src: "getNotifications",
        onError: {
          target: "Error",
          actions: {
            type: "onUpdateErrorMessage",
            params: ({ event }) => ({ error: event.error }),
          },
        },
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
    Error: {
      on: {
        fetch: { target: "Loading" },
      },
    },
  },
});

We can then implement a guard that checks status.

Guards are defined inside setup/guards. They are sync functions that return a boolean:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as {
      notifications: Notification[];
      errorMessage: string;
      status: number | null;
    },
  },
  guards: {
    canReload: ({ context }) => context.status === null || context.status < 500,
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign((_, { error }: { error: unknown }) => ({
      status: error instanceof ApiError ? error.status : null,
      errorMessage: "Error while loading notifications",
    })),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    status: null,
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Loading",
  states: {
    Loading: {
      invoke: {
        src: "getNotifications",
        onError: {
          target: "Error",
          actions: {
            type: "onUpdateErrorMessage",
            params: ({ event }) => ({ error: event.error }),
          },
        },
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
    Error: {
      on: {
        fetch: { target: "Loading" },
      },
    },
  },
});

Using guards we can prevent a transition from happening if the guard returns false.

We add the guard on the fetch transition inside Error:

export const machine = setup({
  types: {
    events: {} as { type: "fetch" },
    context: {} as {
      notifications: Notification[];
      errorMessage: string;
      status: number | null;
    },
  },
  guards: {
    canReload: ({ context }) => context.status === null || context.status < 500,
  },
  actors: { getNotifications },
  actions: {
    onUpdateErrorMessage: assign((_, { error }: { error: unknown }) => ({
      status: error instanceof ApiError ? error.status : null,
      errorMessage: "Error while loading notifications",
    })),
    onUpdateNotifications: assign(
      (_, { notifications }: { notifications: Notification[] }) => ({
        notifications,
      })
    ),
  },
}).createMachine({
  context: {
    status: null,
    errorMessage: "",
    notifications: [
      { id: "1", message: "It's time to work" },
      { id: "2", message: "Up for a meeting?" },
    ],
  },
  initial: "Loading",
  states: {
    Loading: {
      invoke: {
        src: "getNotifications",
        onError: {
          target: "Error",
          actions: {
            type: "onUpdateErrorMessage",
            params: ({ event }) => ({ error: event.error }),
          },
        },
        onDone: {
          target: "Opened",
          actions: {
            type: "onUpdateNotifications",
            params: ({ event }) => ({ notifications: event.output }),
          },
        },
      },
    },
    Opened: {},
    Error: {
      on: {
        fetch: { guard: "canReload", target: "Loading" },
      },
    },
  },
});

Again, since all the logic is defined in the machine, the component needs few updates.

We can use snapshot.can to check a guard. We use this to hide the "Reload" button:

export default function Page() {
  const [snapshot, send] = useMachine(machine);
  return (
    <div>
      {snapshot.matches("Loading") ? (
        <span>Loading...</span>
      ) : snapshot.matches("Error") ? (
        <>
          <span>{snapshot.context.errorMessage}</span>
          {snapshot.can({ type: "fetch" }) && (
            <button onClick={() => send({ type: "fetch" })}>Reload</button>
          )}
        </>
      ) : (
        <>
          {snapshot.context.notifications.map((notification) => (
            <p key={notification.id}>{notification.message}</p>
          ))}
        </>
      )}
    </div>
  );
}

The complexity is encoded inside the machine. The component becomes easier to implement.

We noticed how easy it is to make changes to a state machine as the requirements change compared to useState.

XState can make development faster and easier to maintain long-term.

That's ideal for any app, especially for medium to large scale projects.

👋・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.