Avoid impossible UI states with React, Typescript and XState

Avoid impossible UI states with React, Typescript and XState

Iva Kop's photo
Iva Kop
·Oct 2, 2022·

6 min read

Play this article

To build robust UIs often means to take all possible UI states into consideration and to make sure components behave consistently and correctly throughout. One way to do this is to prevent UIs from getting into impossible states. In this scenario, an impossible UI state means inconsistent, nonsensical or mutually exclusive.

Here's a simple example:

const SchrodingersCat = () => {
  return (
    <Box>
      <Cat dead={true} alive={true} />
    </Box>
  );
};

The famous Schrödinger's cat thought experiment might make a lot of sense when illustrating quantum physics concepts. But, if we turn it into a React component, it can also serve as a good example of an inconsistent UI state. 🐈‍⬛

So how can we avoid impossible UI states and create React components that make sense?

Replace boolean state and props with descriptive ones

How we structure our React components props and state can have a massive impact. Let's replace our Cat example from above with something a little more realistic. Imagine we want to create a Status component with 4 possible states - idle, pending, success and error. How should we go about it?

One way would be to create a boolean prop for these states and make sure to pass the correct one when the status changes. So it would look something like this:

interface StatusProps {
  isIdle?: boolean;
  isPending?: boolean;
  isError?: boolean;
  isSuccess?: boolean;
}

const Status = ({ isIdle, isPending, isError, isSuccess }: StatusProps) => {
  return (
    <div>
      {isIdle && <p>Idle</p>}
      {isPending && <p>Pending</p>}
      {isSuccess && <p>Success</p>}
      {isError && <p>Error</p>}
    </div>
  );
};

// Usage
const App = () => {
  return <Status isIdle />;
};

This is somewhat of a contrived example but, in reality, it can often be tempting to structure props in this way. It's relatively easy to understand and pretty straightforward to implement.

But we can immediately spot a serious problem with this approach. Nothing is preventing us from putting this component in an impossible state.

const App = () => {
  return <Status isIdle isError />;
};

To avoid this, let's ditch the boolean props and create a single descriptive prop in their place.

interface StatusProps {
  status: "idle" | "pending" | "error" | "success";
}

const Status = ({ status }: StatusProps) => {
  return (
    <div>
      {status === "idle" && <p>Idle</p>}
      {status === "pending" && <p>Pending</p>}
      {status === "success" && <p>Success</p>}
      {status === "error" && <p>Error</p>}
    </div>
  );
};

// Usage
const App = () => {
  return <Status status="idle" />;
};

Much better!

But wait! This approach works well when we can fit all the information that we need in a single prop. What if we need to handle multiple props, instead?

Use Typescript discriminated union

Imagine we want to display an error message in our Status component but only when it is in its error state? We also want to pass the text of the error message to Status via a prop. In these more complicated scenarios, we can use Typescript to make sure impossible states are not allowed.

Here's how:

interface StatusPropsDefault {
  status: "idle" | "pending" | "success";
  errorMessage?: never;
}

interface StatusPropsError {
  status: "error";
  errorMessage: string;
}

type StatusProps = StatusPropsDefault | StatusPropsError;

const Status = ({ status, errorMessage }: StatusProps) => {
  return (
    <div>
      {status === "idle" && <p>Idle</p>}
      {status === "pending" && <p>Pending</p>}
      {status === "success" && <p>Success</p>}
      {status === "error" && <p>Error</p>}
      {errorMessage}
    </div>
  );
};

// Usage
const App = () => {
  return (
    <>
      <Status status="idle" />
      <Status status="error" errorMessage="Error message" />
    </>
  );
};

We are using a Typescript discriminated union when we define the types of our props to make sure an error message can only be passed to the component when its status is error. This is awesome and it works great for our use case.

But imagine we have a different challenge. Perhaps we are not only concerned with impossible states but also with impossible state transitions.

Implement a state machine with XState

More concretely, how can we make sure our Status component cannot go from idle directly to success or error without being in a pending state first?

Enter XState.

With XState we can build and visualize type-safe, declarative finite state machines that can ensure states and state transitions always make sense.

Let's see what our Status state machine might look like:

import { createMachine } from "xstate";

// Possible statuses
enum Statuses {
  idle = "idle",
  pending = "pending",
  success = "success",
  error = "error"
}

// Possible state machine events
type MachineEvent =
  | { type: "PENDING" }
  | { type: "SUCCESS" }
  | { type: "ERROR" };

// Possible state machine states
type MachineState =
  | { value: Statuses.idle; context: never }
  | { value: Statuses.pending; context: never }
  | { value: Statuses.success; context: never }
  | { value: Statuses.error; context: never };

export const statusMachine = createMachine<
  undefined, // We are not using the state machine context, so it is set to undefined
  MachineEvent,
  MachineState
>({
  initial: Statuses.idle,
  predictableActionArguments: true,
  states: {
    [Statuses.idle]: {
      on: {
        PENDING: Statuses.pending
      }
    },
    [Statuses.pending]: {
      on: {
        SUCCESS: Statuses.success,
        ERROR: Statuses.error
      }
    },
    [Statuses.success]: { type: "final" },
    [Statuses.error]: { type: "final" }
  }
});

While there is some xState-specific code in the example above, the general idea of the state machine should be straightforward. We always start in an idle state. With a PENDING event, we can transition to pending state . Once we are in this state, we can transition to either success or error, both of which are final states for our state machine.

Let's change our status component to use the same Typescript enum as the state machine state.

interface StatusProps {
  status: Statuses;
}

const Status = ({ status }: StatusProps) => {
  return (
    <div>
      {status === Statuses.idle && <p>Idle</p>}
      {status === Statuses.pending && <p>Pending</p>}
      {status === Statuses.success && <p>Success</p>}
      {status === Statuses.error && <p>Error</p>}
    </div>
  );
};

Finally, let's put it all together in our App component:

const App = () => {
  const [state, send] = useMachine(statusMachine);

  const handleStateChange = () => {
    if (state.matches(Statuses.idle)) {
      send("PENDING");
    } else if (state.matches(Statuses.pending)) {
      // Randomly send either a success or an error event
      if (Math.round(Math.random())) {
        send("SUCCESS");
      } else {
        send("ERROR");
      }
    }
  };

  return (
    <>
      <Status status={state.value as Statuses} />
      <button disabled={state.done} onClick={handleStateChange}>
        Change state
      </button>
    </>
  );
};

The logic inside handleStateChange is clearly just an illustration. Still, our state machine now ensures our component states and state transitions are always consistent and logical.

Not every component needs to be managed with a state machine. And even when we want to have one, it's not always necessary to use xState to build it. Still, especially in more complex scenarios, it can be extremely helpful to have this tool in our tool belt!

Conclusion

There are different approaches to building robust UI components and avoid putting our components in impossible states. Which one we choose for a particular component will always heavily depend on the use case. Hopefully this overview can help make that choice easier and more informed.

Happy coding! ✨

Did you find this article valuable?

Support Iva Kop by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this