Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The steps: a **count** (`3`) or a **list of step ids**
 * (`["cart", "payment", "review"]`). With an array, `stepId` is the current
 * entry — ready to use as an analytics event name or as a panel's key.
 */
export type StepsInput = number | readonly string[]

/**
 * A gate: only `true` lets the move through. `from` is the step index the move
 * **started** from. May return a Promise (async checks: a uniqueness lookup, a

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-step.json

Prompt

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 secondnext() runs canGoNext and 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 / reset bump 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 clears isValidating itself, so a superseded run can't leave a spinner spinning.
  • visited vs completed — being somewhere and finishing something are different facts. visited is written during render (so a controlled parent jumping around is recorded too) and drives "which rail chips are clickable"; completed is filled by a passing gate — it records the step you are leaving — plus explicit markComplete, and drives the checkmarks. goTo only jumps backwards or into visited, unless allowSkip opens 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-effect stays happy. visited / completed prune 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 onStepChange go through a latest-ref, so a gate closing over the current form values (the usual case) is always looking at this render's values, while next / back / goTo / reset / markComplete keep a stable identity. The cost: two next() calls in the same tick advance one step; two real clicks are fine because React commits in between.
  • Empty and single-step flows are legalsteps: 0 (or NaN, or a negative number) degrades to total = 0, stepId === undefined, progress === 0 and no-op controls instead of throwing; with a single step loop reports canNext === false rather than pretending a move to yourself is progress.

On This Page