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! ✨