react-call v2

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 tree
Wizard.tsx
import { 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, once
App.tsx
import { Wizard } from './Wizard'
import { SignupButton } from './SignupButton'
export default function App() {
return (
<>
<Wizard />
<SignupButton />
</>
)
}

The caller

anywhere in your app, imperative
SignupButton.tsx
import { 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) return
const email = await EmailStep.call({ name })
if (!email) return
const 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.

Related examples