Prevent Impossible States Pt. 3 - A UI Driven by State
Use the timer's state machine to drive a React UI, with controls that match its current status.
Introduction
In Part 2, we defined our timer’s states and transitions. Now let’s use that same model to build a React UI.
When someone first opens the timer, it’s idle. They should only see the option to start it. There isn’t a running timer to pause or anything to reset. The controls we offer should follow the timer’s current state.
Connecting the timer to React
Our transition function already takes a state and an event and returns the next state. React’s useReducer lets us reuse that function: React holds the current state, and we send events using dispatch.
Using the types and transition function from Part 2 in a module called timer.ts, we can connect our first button:
import { useReducer } from 'react';
import { transition, type TimerState } from './timer';
const initialState: TimerState = { status: 'idle' };
export const Timer = () => {
const [state, dispatch] = useReducer(transition, initialState);
return (
<section>
<p>Status: {state.status}</p>
{state.status === 'idle' ? (
<button
onClick={() => dispatch({ type: 'start', durationSeconds: 30 })}
>
Start
</button>
) : null}
</section>
);
};Ctrl+Enter or ⌘Enter renders the preview, and it re-renders as you type. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
Starting the timer changes the displayed status and removes the Start button. The click handler only sends the event and the transition rules stay in transition.
Rendering the controls for each state
The reset event is valid in every state, but we don’t need to offer a Reset button while the timer is already idle.
We can use a TimerView component to render the controls for each status:
| Status | Controls |
|---|---|
idle | Start |
running | Pause, Reset |
paused | Resume, Reset |
finished | Reset |
Catching a missing view
What if we forget to handle the paused state? We can use the same exhaustiveness check we used in our transition function. Here’s a shorter view that returns status text, with the paused case deliberately left out:
import type { TimerState } from './timer';
// This view returns status text. It deliberately leaves out 'paused'.
const TimerStatus = ({ state }: { state: TimerState }): string => {
switch (state.status) {
case 'idle':
return 'Ready to start';
case 'running':
return `Running: ${state.remainingSeconds} seconds remaining`;
case 'finished':
return 'Timer finished';
default: {
const unreachable: never = state;
return unreachable;
}
}
};
// Add a 'paused' case above the default, returning the paused status text.
console.log(TimerStatus({ state: { status: 'running', remainingSeconds: 20 } }));Ctrl+Enter or ⌘Enter runs the code. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
At const unreachable: never = state, TypeScript reports:
Type '{ status: "paused"; remainingSeconds: number; }' is not assignable to type 'never'.
We’ve handled the other three statuses, so state can still be a paused timer when it reaches default. Adding this case before default clears the error:
case 'paused':
return `Paused: ${state.remainingSeconds} seconds remaining`;
Try adding it in the editor, then use Reset code to restore the missing-case example.
This checks that we’ve handled every status. It doesn’t check that we’ve included the right buttons inside each case. If we handle paused but forget its Resume button, that still type-checks.
Rendering the buttons
The full component uses that same check. In TimerView.tsx:
import type { Dispatch } from 'react';
import type { TimerEvent, TimerState } from './timer';
type TimerViewProps = {
state: TimerState;
dispatch: Dispatch<TimerEvent>;
};
export const TimerView = ({ state, dispatch }: TimerViewProps) => {
switch (state.status) {
case 'idle':
return (
<button onClick={() => dispatch({ type: 'start', durationSeconds: 30 })}>
Start
</button>
);
case 'running':
return (
<section>
<p>Running: {state.remainingSeconds} seconds remaining</p>
<button onClick={() => dispatch({ type: 'pause' })}>Pause</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</section>
);
case 'paused':
return (
<section>
<p>Paused: {state.remainingSeconds} seconds remaining</p>
<button onClick={() => dispatch({ type: 'resume' })}>Resume</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</section>
);
case 'finished':
return (
<section>
<p>Timer finished</p>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</section>
);
default: {
const unreachable: never = state;
return unreachable;
}
}
};Ctrl+Enter or ⌘Enter renders the preview, and it re-renders as you type. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
Our Timer component can now pass the current state and dispatch to that view. Replace its earlier implementation in Timer.tsx with:
import { useReducer } from 'react';
import { transition, type TimerState } from './timer';
import { TimerView } from './TimerView';
const initialState: TimerState = { status: 'idle' };
export const Timer = () => {
const [state, dispatch] = useReducer(transition, initialState);
return <TimerView state={state} dispatch={dispatch} />;
};Ctrl+Enter or ⌘Enter renders the preview, and it re-renders as you type. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
We’re using our state machine to deterministically render the UI, so we don’t need separate state variables to track whether Pause or Resume should appear.
Sending events from the UI
Our transition function is our API for managing the timer’s state. The button handlers send events to it, rather than deciding the next state themselves. This keeps our transition rules separate from our view logic: the view takes the resulting state and renders the appropriate controls.
I’ve historically been very anti-useEffect. In production, I usually reach for a state management library, or avoid Effects altogether, to keep application logic separate from my view code. For this example, though, a small Effect isolated in a custom hook fits.
Right now the timer shows its remaining time but never counts down, so it needs a clock. We keep that subscription in a custom hook, separate from the view. In useTimer.ts, useReducer manages the state, and useEffect sends a tick event every second while the status is running:
import { useEffect, useReducer } from 'react';
import { transition, type TimerState } from './timer';
const initialState: TimerState = { status: 'idle' };
export const useTimer = () => {
const [state, dispatch] = useReducer(transition, initialState);
useEffect(() => {
if (state.status !== 'running') return;
const intervalId = setInterval(() => dispatch({ type: 'tick' }), 1000);
return () => clearInterval(intervalId);
}, [state.status, dispatch]);
return { state, dispatch };
};Ctrl+Enter or ⌘Enter renders the preview, and it re-renders as you type. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
Timer now calls useTimer() where it called useReducer before. TimerView doesn’t change. This Effect only synchronizes with the browser clock, so our rendering logic still derives directly from state. It depends on state.status and dispatch, so updating remainingSeconds doesn’t restart the interval. Because it counts interval callbacks, it can drift from wall-clock time.
When a running timer has one second left, the next tick returns { status: 'finished' }, which has no remainingSeconds, so the view offers only Reset.
The UI only offers actions appropriate to its current state, but other code can still send an event. If pause arrives while the timer is idle, our transition function intentionally leaves the state unchanged.
Adding more timers
We can take this a step further and let the user create multiple timer widgets. Each Timer calls useTimer, so each instance has its own state. Pausing or resetting one timer doesn’t change another timer’s state.
In Timers.tsx:
import { useState } from 'react';
import { Timer } from './Timer';
export const Timers = () => {
const [timerIds, setTimerIds] = useState<string[]>([]);
const createTimer = () => {
const id = crypto.randomUUID();
setTimerIds(ids => [...ids, id]);
};
return (
<section>
<button type="button" onClick={createTimer}>Create timer</button>
<ul>
{timerIds.map((id, index) => (
<li key={id}>
<h2>Timer {index + 1}</h2>
<Timer />
</li>
))}
</ul>
</section>
);
};Ctrl+Enter or ⌘Enter renders the preview, and it re-renders as you type. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
The parent keeps track of which timers exist. Each timer keeps track of its own status and remaining time. We create each ID when the user clicks the button and keep it as a stable key, so React can identify the existing timers when we add another one.
Try creating two timers, starting both, and pausing or resetting just one. The other timer should keep its state.
The lesson
Our UI stays in sync with our timer’s state because that’s what determines the controls we render. A paused timer offers Resume and Reset, and each timer widget manages its state independently. The exhaustiveness check catches missing state cases, but we still need to check that each view offers the controls we intended.