Created
May 7, 2026 21:29
-
-
Save alexcarpenter/1cfc81bccc5d0869b382161caea406f9 to your computer and use it in GitHub Desktop.
wizard-prototype
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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. |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
comparing approach to support url driven steps + redirects:
claude summary: