Skip to content

Instantly share code, notes, and snippets.

@alexcarpenter
Created May 7, 2026 21:29
Show Gist options
  • Select an option

  • Save alexcarpenter/1cfc81bccc5d0869b382161caea406f9 to your computer and use it in GitHub Desktop.

Select an option

Save alexcarpenter/1cfc81bccc5d0869b382161caea406f9 to your computer and use it in GitHub Desktop.
wizard-prototype
/**
* Wizard State Machine — Prototype
*
* Unified wizard primitive inspired by XState 5. Steps, guards,
* sub-steps, and transitions are defined as static data. Guard
* implementations are provided separately (like XState's `setup()`)
* where they have access to React hooks and reactive state.
*
* The reducer handles pure navigation. Guards are resolved in the
* hook layer before dispatching — keeping the reducer testable
* without mocking.
*/
import React from 'react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WizardSubStep {
id: string;
/** Named condition — references a key in the `guards` record.
* Sub-step is only active when the guard returns true. */
when?: string;
}
export interface WizardStep {
id: string;
label?: string;
/** Hidden from stepper breadcrumb, still participates in navigation */
hidden?: boolean;
/** Named guard — references a key in the `guards` record.
* Evaluated before entering this step. */
guard?: string;
/** Hierarchical sub-steps. goNext/goPrev navigate within sub-steps
* first, then bubble to the parent on overflow. */
steps?: WizardSubStep[];
}
/** Guard implementations provided at the component level.
* Step definitions reference these by string key — same pattern
* as XState's `setup({ guards: { ... } })`. */
type WizardGuards = Record<string, () => boolean>;
/** 1 = forward, -1 = backward. Usable as an animation multiplier. */
type Direction = 1 | -1;
type WizardEvent =
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'GO_TO'; stepId: string };
interface WizardState {
stepId: string;
direction: Direction | null;
}
// ---------------------------------------------------------------------------
// Reducer — pure navigation, no guard logic
// ---------------------------------------------------------------------------
/**
* The reducer only handles state transitions. It does not evaluate
* guards — that happens in the hook before dispatch. This keeps the
* reducer unit-testable with no mocking required.
*/
function wizardReducer(steps: WizardStep[]) {
return (state: WizardState, event: WizardEvent): WizardState => {
const currentIndex = steps.findIndex(s => s.id === state.stepId);
switch (event.type) {
case 'NEXT': {
const next = steps[currentIndex + 1];
if (!next) return state;
return { stepId: next.id, direction: 1 };
}
case 'PREV': {
const prev = steps[currentIndex - 1];
if (!prev) return state;
return { stepId: prev.id, direction: -1 };
}
case 'GO_TO': {
const targetIndex = steps.findIndex(s => s.id === event.stepId);
if (targetIndex < 0) return state;
return {
stepId: event.stepId,
direction: targetIndex > currentIndex ? 1 : -1,
};
}
}
};
}
/**
* Sub-step reducer — handles navigation within a step's sub-steps.
* Separated from the main reducer because sub-step resolution depends
* on guard evaluation (the `when` conditions).
*/
function subStepReducer(
activeSubSteps: WizardSubStep[],
subStepId: string | null,
event: 'NEXT' | 'PREV',
): { subStepId: string | null; handled: boolean } {
if (activeSubSteps.length === 0) {
return { subStepId: null, handled: false };
}
const subIndex = subStepId ? activeSubSteps.findIndex(s => s.id === subStepId) : -1;
if (event === 'NEXT') {
const next = activeSubSteps[subIndex + 1];
if (next) return { subStepId: next.id, handled: true };
// Last sub-step — bubble to parent
return { subStepId, handled: false };
}
if (event === 'PREV') {
const prev = activeSubSteps[subIndex - 1];
if (prev) return { subStepId: prev.id, handled: true };
// First sub-step — bubble to parent
return { subStepId, handled: false };
}
return { subStepId, handled: false };
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface WizardMachineConfig {
steps: WizardStep[];
guards?: WizardGuards;
initialStepId?: string;
}
interface WizardMachineReturn {
stepId: string;
subStepId: string | null;
currentStep: WizardStep;
currentIndex: number;
direction: Direction | null;
visibleSteps: WizardStep[];
activeSubSteps: WizardSubStep[];
subStepIndex: number;
totalSubSteps: number;
isFirstStep: boolean;
isLastStep: boolean;
canGoNext: boolean;
canGoPrev: boolean;
goNext: () => void;
goPrev: () => void;
goToStep: (id: string) => void;
}
function useWizardMachine({ steps, guards = {}, initialStepId }: WizardMachineConfig): WizardMachineReturn {
const guardsRef = React.useRef(guards);
guardsRef.current = guards;
const checkGuard = React.useCallback((name?: string): boolean => {
if (!name) return true;
return guardsRef.current[name]?.() ?? true;
}, []);
const reducer = React.useMemo(() => wizardReducer(steps), [steps]);
const [state, dispatch] = React.useReducer(reducer, {
stepId: initialStepId ?? steps[0].id,
direction: null,
});
const currentIndex = steps.findIndex(s => s.id === state.stepId);
const currentStep = steps[currentIndex];
const visibleSteps = React.useMemo(() => steps.filter(s => !s.hidden), [steps]);
const nextStep = steps[currentIndex + 1];
const activeSubSteps = React.useMemo(
() => currentStep?.steps?.filter(s => checkGuard(s.when)) ?? [],
[currentStep, checkGuard],
);
// Sub-step navigation state. Tracked separately from the main
// reducer because sub-step resolution depends on guard evaluation.
const [subNav, setSubNav] = React.useState<{ id: string | null; direction: Direction | null }>({
id: null,
direction: null,
});
// Reset sub-nav when the main step changes
React.useEffect(() => {
setSubNav({ id: null, direction: null });
}, [state.stepId]);
// Resolve the effective sub-step: explicit sub-nav > direction-aware default > null
const subStepId = React.useMemo(() => {
if (activeSubSteps.length === 0) return null;
if (subNav.id && activeSubSteps.some(s => s.id === subNav.id)) return subNav.id;
// Entering from PREV → land on last sub-step; otherwise first
if (state.direction === -1) return activeSubSteps[activeSubSteps.length - 1]?.id ?? null;
return activeSubSteps[0]?.id ?? null;
}, [activeSubSteps, subNav.id, state.direction]);
const subStepIndex = subStepId ? activeSubSteps.findIndex(s => s.id === subStepId) : -1;
const isOnFirstSub = activeSubSteps.length === 0 || subStepIndex <= 0;
const isOnLastSub = activeSubSteps.length === 0 || subStepIndex === activeSubSteps.length - 1;
const direction = subNav.id ? subNav.direction : state.direction;
const goNext = React.useCallback(() => {
const sub = subStepReducer(activeSubSteps, subStepId, 'NEXT');
if (sub.handled) {
setSubNav({ id: sub.subStepId, direction: 1 });
return;
}
if (nextStep && !checkGuard(nextStep.guard)) return;
dispatch({ type: 'NEXT' });
}, [activeSubSteps, subStepId, nextStep, checkGuard]);
const goPrev = React.useCallback(() => {
const sub = subStepReducer(activeSubSteps, subStepId, 'PREV');
if (sub.handled) {
setSubNav({ id: sub.subStepId, direction: -1 });
return;
}
dispatch({ type: 'PREV' });
}, [activeSubSteps, subStepId]);
const goToStep = React.useCallback(
(id: string) => {
const target = steps.find(s => s.id === id);
if (!target || !checkGuard(target.guard)) return;
dispatch({ type: 'GO_TO', stepId: id });
},
[steps, checkGuard],
);
return React.useMemo(
() => ({
stepId: state.stepId,
subStepId,
currentStep,
currentIndex,
direction,
visibleSteps,
activeSubSteps,
subStepIndex,
totalSubSteps: activeSubSteps.length,
isFirstStep: currentIndex === 0 && isOnFirstSub,
isLastStep: currentIndex === steps.length - 1 && isOnLastSub,
canGoNext: isOnLastSub ? !!nextStep && checkGuard(nextStep.guard) : true,
canGoPrev: currentIndex > 0 || !isOnFirstSub,
goNext,
goPrev,
goToStep,
}),
[state.stepId, subStepId, currentStep, currentIndex, direction, visibleSteps, activeSubSteps, subStepIndex, isOnFirstSub, isOnLastSub, nextStep, steps.length, checkGuard, goNext, goPrev, goToStep],
);
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
const WizardMachineContext = React.createContext<WizardMachineReturn | null>(null);
function useWizard(): WizardMachineReturn {
const ctx = React.useContext(WizardMachineContext);
if (!ctx) throw new Error('useWizard must be used within <Wizard>');
return ctx;
}
function Root({ config, children }: { config: WizardMachineConfig; children: React.ReactNode }): JSX.Element {
const machine = useWizardMachine(config);
return <WizardMachineContext.Provider value={machine}>{children}</WizardMachineContext.Provider>;
}
function Match({ id, children }: { id: string; children: React.ReactNode }): JSX.Element | null {
const { stepId } = useWizard();
if (stepId !== id) return null;
return <>{children}</>;
}
function SubMatch({ id, children }: { id: string; children: React.ReactNode }): JSX.Element | null {
const { subStepId } = useWizard();
if (subStepId !== id) return null;
return <>{children}</>;
}
function StepperIndicator(): JSX.Element {
const { visibleSteps, currentStep, goToStep } = useWizard();
const currentVisibleIndex = visibleSteps.findIndex(s => s.id === currentStep?.id);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{visibleSteps.map((step, index) => {
const isCurrent = step.id === currentStep?.id;
const isCompleted = index < currentVisibleIndex;
return (
<React.Fragment key={step.id}>
{index > 0 && <span>{'>'}</span>}
<button
disabled={!isCompleted && !isCurrent}
onClick={() => goToStep(step.id)}
style={{ fontWeight: isCurrent ? 'bold' : 'normal' }}
>
{isCompleted ? '\u2713' : index + 1} {step.label}
</button>
</React.Fragment>
);
})}
</div>
);
}
export const Wizard = Object.assign(Root, {
Match,
SubMatch,
Stepper: StepperIndicator,
useWizard,
});
// ---------------------------------------------------------------------------
// Usage: ConfigureSSO
// ---------------------------------------------------------------------------
// Step definitions — static, module-level. Guards are string keys,
// not functions. This is the machine definition.
const SSO_STEPS: WizardStep[] = [
{ id: 'select-provider', hidden: true },
{
id: 'verify-domain',
label: 'Verify domain',
steps: [
{ id: 'provide-email', when: 'needsEmail' },
{ id: 'verify-email-address' },
],
},
{ id: 'configure', label: 'Configure', guard: 'domainVerified' },
{ id: 'test', label: 'Test', guard: 'domainVerified' },
{ id: 'confirmation', label: 'Done' },
];
// Stubs
type EnterpriseConnectionResource = { domainVerified: boolean };
declare function useUser(): { user: { primaryEmailAddress: string | null } };
declare function useConfigureSSOFlow(): { enterpriseConnection: EnterpriseConnectionResource | undefined };
// Guard implementations — defined in the component where hooks
// are available. Same pattern as XState's setup({ guards }).
export function ConfigureSSOCardContent() {
const { user } = useUser();
const { enterpriseConnection } = useConfigureSSOFlow();
const config: WizardMachineConfig = React.useMemo(
() => ({
steps: SSO_STEPS,
guards: {
needsEmail: () => !user.primaryEmailAddress,
domainVerified: () => !!enterpriseConnection?.domainVerified,
},
}),
[user.primaryEmailAddress, enterpriseConnection?.domainVerified],
);
return (
<Wizard config={config}>
<Wizard.Stepper />
<Wizard.Match id='select-provider'>
<SelectProviderStep />
</Wizard.Match>
<Wizard.Match id='verify-domain'>
<VerifyDomainHeader />
<Wizard.SubMatch id='provide-email'>
<ProvideEmailStep />
</Wizard.SubMatch>
<Wizard.SubMatch id='verify-email-address'>
<EnterVerificationCodeStep />
</Wizard.SubMatch>
</Wizard.Match>
<Wizard.Match id='configure'>
<ConfigureStepComponent />
</Wizard.Match>
<Wizard.Match id='test'>
<TestConfigurationStep />
</Wizard.Match>
<Wizard.Match id='confirmation'>
<ConfirmationStep />
</Wizard.Match>
</Wizard>
);
}
function VerifyDomainHeader() {
const { subStepIndex, totalSubSteps } = Wizard.useWizard();
return (
<div>
<h2>Verify your email address</h2>
{totalSubSteps > 1 && (
<span>
Step {subStepIndex + 1}/{totalSubSteps}
</span>
)}
</div>
);
}
function SelectProviderStep() {
const { goNext, isFirstStep, isLastStep } = Wizard.useWizard();
return (
<div>
<h2>Select provider</h2>
<button disabled={isFirstStep}>Previous</button>
<button onClick={goNext} disabled={isLastStep}>
Continue
</button>
</div>
);
}
function ProvideEmailStep() {
const { goNext, goPrev, isFirstStep } = Wizard.useWizard();
return (
<div>
<button onClick={goPrev} disabled={isFirstStep}>
Previous
</button>
<button onClick={goNext}>Continue</button>
</div>
);
}
function EnterVerificationCodeStep() {
const { goNext, goPrev } = Wizard.useWizard();
return (
<div>
<button onClick={goPrev}>Previous</button>
<button onClick={goNext}>Continue</button>
</div>
);
}
function ConfigureStepComponent() {
const { goNext, goPrev } = Wizard.useWizard();
return (
<div>
<button onClick={goPrev}>Previous</button>
<button onClick={goNext}>Continue</button>
</div>
);
}
function TestConfigurationStep() {
const { goNext, goPrev } = Wizard.useWizard();
return (
<div>
<button onClick={goPrev}>Previous</button>
<button onClick={goNext}>Continue</button>
</div>
);
}
function ConfirmationStep() {
const { goPrev, isFirstStep } = Wizard.useWizard();
return (
<div>
<button onClick={goPrev} disabled={isFirstStep}>
Previous
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Proposal
// ---------------------------------------------------------------------------
//
// The current Wizard describes itself as "declarative" and "state-driven,"
// but the React component tree IS the state. Steps register themselves
// via useLayoutEffect on mount. The step graph is a runtime artifact of
// what's currently rendered — it doesn't exist until the tree mounts,
// can't be inspected without rendering, and changes shape as components
// mount and unmount. Conditional steps (`{cond && <Wizard.Step>}`) mean
// the machine's own structure is controlled by JSX branching, not by
// the machine itself.
//
// This prototype inverts that relationship. The step graph is static
// data defined at module scope. The machine owns the structure; React
// renders whatever the machine says is active. Steps don't register
// themselves — they don't need to, because the machine already knows
// what exists.
//
// Concretely, this enables:
//
// - Guards that gate transitions at the machine level. The current
// approach has no guard mechanism — each step independently manages
// its own button's disabled state, but nothing prevents a
// programmatic goToStep() from bypassing preconditions. Named
// guards (like XState's setup({ guards })) enforce invariants
// in one place.
//
// - Sub-steps as hierarchical data. The current approach nests a
// second <Wizard> component with its own context and uses
// parentWizard fall-through for boundary transitions. This
// prototype declares sub-steps on the step definition with
// conditional `when` guards — no nested providers, no context
// shadowing, no extra registration effects.
//
// - Direction tracking (1 | -1) on every transition, enabling
// directional content animations. Not possible in the current
// approach because transitions don't record which way they went.
//
// - A built-in stepper that derives directly from machine state.
// The current approach requires a bridge component that reads
// useWizard(), filters out specific step IDs by name, and
// manually derives isCompleted/isReachable/isCurrent for each
// stepper item. That bridge disappears entirely.
//
// - A pure reducer that's unit-testable without mounting React.
// The current approach can only be tested through component
// rendering because the step graph depends on useLayoutEffect
// registration.
//
// Domain state stays in existing providers and hooks. The wizard
// owns navigation and transition logic, not data.
@alexcarpenter

alexcarpenter commented May 7, 2026 •

Copy link
Copy Markdown
Author

comparing approach to support url driven steps + redirects:

claude summary:

Current approach has a chicken-and-egg problem. The wizard doesn't know what steps exist until they mount and register via useLayoutEffect. If you want to resume from a URL (say
/configure-sso?step=configure), you'd parse the URL, pass initialStepId='configure', and then wait for all the <Wizard.Step> components to fire their registration effects.
Until those effects run, currentStepId is set to 'configure' but activeSteps is still empty — currentStep is undefined, currentIndex is -1, isFirstStep/isLastStep are wrong.
There's a frame where the machine's state is internally inconsistent.

It gets worse with conditional steps. If provide-email is gated on !primaryEmailAddress, and that data comes from useUser() which might still be loading on refresh, the step
graph at mount time could be different from the step graph once data settles. The machine's shape depends on when hooks resolve.

The prototype doesn't have this problem. The steps array exists at module scope before anything renders. You can validate a URL parameter against SSO_STEPS synchronously — check
if the step ID exists, check if its guard passes, fall back to the first step if not. The machine's state is consistent from the first render because it doesn't depend on
effects to know its own shape.

Sub-steps work too. A URL like ?step=verify-domain&sub=verify-email-address can be validated against the step's steps array and its when guard immediately. The current approach
would need the nested to mount and its inner steps to register before you could even confirm that sub-step exists.

Neither approach currently implements URL sync — but the prototype is ready for it and the registration approach would need workarounds to handle the timing gap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment