Multi-step wizard
A signup flow with three steps and a back/forward navigation. State lives inside the Callable; the caller awaits a single structured response.
→ no signup yet
The Callable
declared once, mounted in the React treeimport { useEffect, useRef, useState } from 'react'import { createCallable } from 'react-call'
export interface WizardResult { name: string email: string plan: 'free' | 'pro' | 'team'}
export const Wizard = createCallable<void, WizardResult | null>(({ call }) => { const [step, setStep] = useState(0) const [name, setName] = useState('') const [email, setEmail] = useState('') const [plan, setPlan] = useState<WizardResult['plan']>('free') const nameInputRef = useRef<HTMLInputElement>(null) const emailInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { if (step === 0) nameInputRef.current?.focus() if (step === 1) emailInputRef.current?.focus() }, [step])
const next = () => setStep((s) => s + 1) const back = () => setStep((s) => s - 1) const cancel = () => call.end(null) const finish = () => call.end({ name, email, plan })
return ( <div role="dialog" aria-modal="true" aria-label="Wizard" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" > <div className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-6 shadow-2xl"> <div className="mb-5 flex items-center gap-2"> {[0, 1, 2].map((i) => ( <div key={i} className={ i <= step ? 'h-1 flex-1 rounded-full bg-[var(--color-accent)]' : 'h-1 flex-1 rounded-full bg-[var(--color-bg-muted)]' } /> ))} </div>
{step === 0 && ( <Step title="Your name" hint={`Step 1 of 3`}> <input ref={nameInputRef} type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ada Lovelace" className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-sm text-[var(--color-fg)] focus:border-[var(--color-accent)] focus:outline-none" /> </Step> )}
{step === 1 && ( <Step title="Your email" hint="Step 2 of 3"> <input ref={emailInputRef} type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="ada@example.com" className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-sm text-[var(--color-fg)] focus:border-[var(--color-accent)] focus:outline-none" /> </Step> )}
{step === 2 && ( <Step title="Pick a plan" hint="Step 3 of 3"> <div className="grid grid-cols-3 gap-2"> {(['free', 'pro', 'team'] as const).map((p) => ( <button key={p} type="button" onClick={() => setPlan(p)} className={ p === plan ? 'rounded-md border border-[var(--color-accent)] bg-[var(--color-bg-subtle)] px-3 py-2 text-sm font-medium text-[var(--color-accent)]' : 'rounded-md border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-sm text-[var(--color-fg-muted)] transition-colors hover:text-[var(--color-fg)]' } > {p} </button> ))} </div> </Step> )}
<div className="mt-6 flex items-center justify-between"> <button type="button" onClick={cancel} className="text-sm text-[var(--color-fg-subtle)] hover:text-[var(--color-fg-muted)]" > Cancel </button> <div className="flex gap-2"> {step > 0 && ( <button type="button" onClick={back} className="rounded-md border border-[var(--color-border)] px-4 py-2 text-sm text-[var(--color-fg-muted)] transition-colors hover:text-[var(--color-fg)]" > Back </button> )} {step < 2 ? ( <button type="button" onClick={next} disabled={ (step === 0 && !name.trim()) || (step === 1 && !email.trim()) } className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)] disabled:opacity-50" > Next </button> ) : ( <button type="button" onClick={finish} className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)]" > Finish </button> )} </div> </div> </div> </div> )})Wizard.displayName = 'Wizard'
interface StepProps { title: string hint: string children: React.ReactNode}
const Step = ({ title, hint, children }: StepProps) => ( <> <p className="font-mono text-xs uppercase tracking-wider text-[var(--color-fg-subtle)]"> {hint} </p> <p className="mt-1 text-base font-medium text-[var(--color-fg)]">{title}</p> <div className="mt-4">{children}</div> </>)The Root
mounted in your app tree, onceimport { Wizard } from './Wizard'import { SignupButton } from './SignupButton'
export default function App() { return ( <> <Wizard /> <SignupButton /> </> )}The caller
anywhere in your app, imperativeimport { useState } from 'react'import { type WizardResult, Wizard } from './Wizard'
export const SignupButton = () => { const [result, setResult] = useState<WizardResult | null>(null)
const handleClick = async () => { const data = await Wizard.call() if (data) setResult(data) }
return ( <div className="flex flex-col items-center gap-3"> <button type="button" onClick={handleClick} className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)]" > Sign up </button> <span className="font-mono text-xs text-[var(--color-fg-subtle)]"> {result ? ( <span className="text-[var(--color-accent)]"> → {result.name} ({result.plan}) </span> ) : ( '→ no signup yet' )} </span> </div> )}Why a single Callable, not three
You could chain three separate .call()s — one per step — and the
flow would even read clean as imperative code:
const name = await NameStep.call({})if (!name) returnconst email = await EmailStep.call({ name })if (!email) returnconst plan = await PlanStep.call({ name, email })It works. But the back button doesn’t. Cancelling a child step from inside it can’t re-open the previous step — the previous promise has already resolved. You’d own a state machine in caller scope to drive the back navigation.
A single Callable with internal step state keeps the state machine where it belongs (inside the wizard) and exposes a clean single-promise interface to the caller. The price is that the Callable is bigger; the gain is that the caller doesn’t think about steps at all.
When to reach for this shape
- Multi-step forms with linear back/forward navigation.
- Onboarding flows where the user can abandon at any point with
null.