useStep
A step machine for multi-step flows — async can-go-next gates with a pending flag, visited/completed sets, loop, clamping when steps change, controlled or uncontrolled.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-step.jsonPrompt
Build a React + TypeScript "useStep" hook (React only, no other dependencies).
It renders nothing and touches no DOM — it is the state machine behind a
multi-step flow (wizard, onboarding, checkout).
Contract
- `useStep({ steps, step, defaultStep = 0, onStepChange, loop = false,
canGoNext, canGoBack, allowSkip = false }): { step, stepId, total, next,
back, goTo, reset, isFirst, isLast, progress, visited, completed,
markComplete, canNext, canBack, isValidating }`.
- `steps: number | readonly string[]` — a step count, or a list of step ids.
It may change at runtime. Non-finite / negative counts fall back to 0.
- `step` switches the hook to controlled mode (the caller owns the index);
without it the hook keeps internal state seeded once from `defaultStep`,
exactly like `useState`.
- `canGoNext?: (from: number) => boolean | Promise<boolean>` and the symmetric
`canGoBack` are gates: `next()` / `back()` run them and only move on `true`.
- Returned: `step` (always clamped into `[0, total - 1]`, `0` when the flow is
empty), `stepId` (`steps[step]` for an id array, `String(step)` for a count,
`undefined` only when `total === 0`), `total`, `progress` (0..1, current step
included: step 1 of 3 is 1/3, the last step is 1), `isFirst` / `isLast`
(geometric position, unaffected by `loop`), `canNext` / `canBack` (is there
anywhere to go, `loop` included — NOT the gate's verdict, which can only be
known by running it), `isValidating` (an async gate is in flight).
- `next()` / `back()` return `Promise<boolean>` — "did the step actually
change" — and never reject. `goTo(index)` returns a boolean synchronously.
`reset()` and `markComplete(index?, complete = true)` return void.
- `visited: ReadonlySet<number>` (steps reached, current one always included)
and `completed: ReadonlySet<number>` (steps finished) are different things
and must both be returned; `completed` is filled by a passing `next()` gate
(it records the step being LEFT) plus explicit `markComplete`.
Behavior
- Gate first, move second. Never move the step optimistically and roll it back
on refusal: a rejected gate must not flash the next panel for a frame.
- A gate returning a promise sets `isValidating` while it settles. A sync gate
must NOT set it (that would cost an extra render for nothing).
- Wrap the gate call in try/catch: a synchronous `throw` escapes before
`Promise.resolve` can capture it and would leave the button pending forever.
A throw or a rejection counts as a refusal — the flow never advances on an
error. The hook has no channel for the reason: the gate is the consumer's
own function, so it should write the failure message into its own state
before returning false.
- After `await`, re-check three things before committing: the component is
still mounted, this run is still the newest one (bump a run id at the start
of every move and every `goTo`/`reset`), and the user is still standing on
the step the gate was asked about. A late "yes" must never yank back
someone who already pressed Back, and a verdict about step 2 must never be
applied from step 1.
- Every path that has NOT awaited clears `isValidating` unconditionally — the
run that just claimed the id owns the flag. Without this, "press Next then
press Back" leaves a spinner running forever, because the superseded run
returns early and never clears it.
- `goTo(index)` runs no gates (it is an explicit jump, not flow progress) and
cancels an in-flight gate. Permission rule: backwards is always allowed,
forwards only into a step already in `visited` — unless `allowSkip` is true.
Out-of-range or same-step targets return false and do nothing.
- `loop` makes `next()` on the last step wrap to 0 and `back()` on the first
step wrap to the last. With `total <= 1` there is nowhere to go, so
`canNext` / `canBack` stay false instead of reporting a self-move.
- `steps` shrinking under a live flow (steps removed) clamps the current step
DURING RENDER (`if (!controlled && step !== stateStep) setStateStep(step)`),
never with a `setState` inside an effect — an effect commits one extra frame
pointing at a step that no longer exists, and the eslint rule
`react-hooks/set-state-in-effect` rejects it anyway (it analyses across
function boundaries, so hiding the call in a helper does not help).
`visited` / `completed` prune indices
that fell out of range, returning the SAME set reference when nothing was
pruned so consumers can put them in dependency arrays.
- Auto-clamping does not call `onStepChange` (it is a derivation, not a
navigation) and, in controlled mode, never rewrites the caller's state —
render from the returned `step`.
- The current step is added to `visited` during render, so a controlled parent
jumping around is recorded too; seed the set from the first rendered index
(a flow starting at step 2 has not visited steps 0 and 1).
- `onStepChange` and both gates are read through a ref refreshed on every
render (latest-ref), so inline arrow functions and gates closing over fresh
form values work without invalidating anything.
- All returned functions are `useCallback([])`-stable. The trade-off, which
must be documented: two `next()` calls in the SAME tick advance one step and
fire `onStepChange` twice with the same value, because the second call reads
the same ref-synced index. Two real clicks are unaffected (React commits in
between). Callers who want to jump two steps use `goTo(step + 2)`.
- `steps: 0 / -3 / NaN / Infinity` degrade to an empty flow: `total = 0`,
`stepId` undefined, `progress` 0, every move a no-op — no throw, no loop.
Rendering & styling
- The hook renders no DOM and manages no focus. Consumer UI: drive the rail
from `visited` / `completed` / `step`, disable Next with
`disabled={!canNext || isValidating}`, and render the pending state with an
`aria-busy` button plus a spinner that respects
`motion-reduce:animate-none`. Put the refusal message in a `role="alert"`
node and move focus to the new panel heading yourself on every step change
(`tabIndex={-1}` + `focus()`), because the hook deliberately does not.
- Semantic tokens only: `bg-primary` for the filled progress track over
`bg-muted`, `border-primary bg-primary/10` for the current step chip,
`text-muted-foreground` for locked steps, `text-destructive` +
`border-destructive/40 bg-destructive/5` for the failure banner,
`focus-visible:ring-2 ring-ring`, `tabular-nums` on "step 2 of 4".
Customization levers
- `steps` — a count for anonymous stages, an id array when you want `stepId`
as a panel key / analytics event name. Feed it from state to add or remove
stages at runtime; the hook keeps the index in range for you.
- `canGoNext` / `canGoBack` — omit for a free-running flow, return a boolean
for local field validation, return a promise for a server check (uniqueness,
stock, payment authorization). Same signature either way.
- `allowSkip` — `false` for a strict wizard rail (locked steps unreachable),
`true` for a tour or a settings flow where every stage is always reachable.
- `loop` — `true` for a carousel-ish onboarding that cycles, `false` for a
checkout that must end.
- Controlled vs uncontrolled — pass `step` + `onStepChange` to sync with a
`?step=` query param, a router segment or a parent store; omit both for a
self-contained flow.
- Progress convention — `progress` counts the current step. For "position
along the rail" (first step empty, last step full) use
`total > 1 ? step / (total - 1) : 1`; for "how much is finished" use
`completed.size / total`.
- `markComplete(index, false)` — un-complete a step when editing an earlier
answer invalidates a later one.Concepts
- Gate first, move second —
next()runscanGoNextand only then writes the new index. The hook never moves optimistically and rolls back, because a rollback is a visible flash of the wrong panel. A gate that throws or rejects counts as a refusal, so a flaky check can never push the flow forward. - Stale-verdict cancellation — every move claims a run id, and
goTo/resetbump it too. When an async gate finally settles, the hook re-checks that it is still mounted, still the newest run, and still on the step the gate was asked about; otherwise the answer is dropped. This is what keeps a slow "yes" from yanking forward a user who already pressed Back — and every non-awaiting path clearsisValidatingitself, so a superseded run can't leave a spinner spinning. visitedvscompleted— being somewhere and finishing something are different facts.visitedis written during render (so a controlled parent jumping around is recorded too) and drives "which rail chips are clickable";completedis filled by a passing gate — it records the step you are leaving — plus explicitmarkComplete, and drives the checkmarks.goToonly jumps backwards or intovisited, unlessallowSkipopens it up.- Clamp on shrink, written back during render — removing steps from a live flow can delete the one you are standing on. The hook clamps to the new last step during render (adjust-state-during-render) rather than from an effect: no committed frame pointing at a step that no longer exists, and
react-hooks/set-state-in-effectstays happy.visited/completedprune out-of-range indices — and return the same set reference when there was nothing to prune, so they are safe in dependency arrays. - The gates read fresh values — gates and
onStepChangego through a latest-ref, so a gate closing over the current form values (the usual case) is always looking at this render's values, whilenext/back/goTo/reset/markCompletekeep a stable identity. The cost: twonext()calls in the same tick advance one step; two real clicks are fine because React commits in between. - Empty and single-step flows are legal —
steps: 0(orNaN, or a negative number) degrades tototal = 0,stepId === undefined,progress === 0and no-op controls instead of throwing; with a single steploopreportscanNext === falserather than pretending a move to yourself is progress.
useMutationObserver
A callback-ref hook that watches DOM changes with MutationObserver — filtered attributes, rAF-batched records, and a disconnect/write/observe guard against self-feeding loops.
useList
Array state with every mutation you keep hand-writing — push, insertAt, move, upsert and friends, all batch-safe, out-of-range-safe, and reference-stable.