Skip to main content

Core Concepts

The Paradigm

Most state machine libraries (XState, Zag, etc.) require you to define all states and transitions upfront in a centralized config. The machine is rigid and "above" the components in the control hierarchy.

react-sequent inverts this. Flows are implicit — transitions emerge from step-level logic rather than a global declaration. This means:

  • Add, remove, or reorder steps without touching a central config.
  • Branching logic lives where it's most readable — in the step itself.
  • Steps are easy to test in isolation because each one is self-contained.

This is the library's entire reason for existing.

There is a second architectural difference that follows from this design: flow state lives independently of the component that starts it. Call init() from a button click, a useEffect, or a parent component — the outlet doesn't care. The flow isn't tethered to the caller's React lifecycle; it runs inside the outlet's provider boundary, which stays mounted as long as the outlet is rendered. This means a single outlet can host multiple unrelated flows over its lifetime — start a login flow, tear it down, start a checkout flow — without remounting chrome, losing context, or wrapping each flow in its own closure. There are no dangling active or inactive flags to track per flow; the library manages outlet state internally, so consumers never need to manage lifecycle state at all, not even remembering to tear down a completed or aborted flow.

Vocabulary

TermMeaning
FlowA sequence of steps managed as a unit
StepA discrete state in a flow; owns its own transition logic
AdvanceMove to the next step, as determined by the current step's logic
RetreatMove to the previous step by popping the history stack
ContextFlow-scoped data the library carries and makes available to all steps
OutletThe mount point where the active step renders (<SequentOutlet />)
ChromeStable UI (headers, progress bars) rendered inside the outlet but outside the step
ResolveA flow completes successfully, returning a value
AbortA flow exits without completing

Hook Separation

The library exposes a primary flow hook plus two runtime hooks with strictly separated concerns:

useSequentFlow — for flow entry points

  • Returns init to start a flow
  • Returns a bound <SequentOutlet /> component for that hook instance
  • Has no knowledge of step internals

useSequentStep — for step components

  • Returns advance, retreat, resolve, abort, and context
  • Has no access to initializer-level capabilities

useSequentContext — for chrome components

  • Returns the current flow context value, plus resolve and abort for flow termination
  • Available to any component inside the outlet's flow-level provider boundary (chrome, idle children, and steps)
  • Has no navigation capabilities — cannot advance or retreat

This compartmentalization is a hard requirement, not a preference. A step must never be able to access initializer-level capabilities, and vice versa.

useSequentStep() is enforced at the React tree level — it throws immediately if called outside the active step's subtree (e.g. from chrome or an idle child). The correct hook for those contexts is useSequentContext().

Outlet Lifecycle

idle ──init()──▶ active ──resolve()/abort()──▶ idle

When idle, the outlet renders its children (if any) and otherwise renders nothing. When activated via init(), it renders the active step (and any chrome you place inside the outlet). Calling resolve() or abort() tears down the flow and returns the outlet to idle.

The consumer never manages this boolean directly — it's derived entirely from whether a flow has been initialized against the outlet.

History & Retreat

retreat() navigates backward by popping the history stack. It does not restore step-local state (e.g. useState values).

If you need state preserved across retreat, write it to context via advance(NextStep, { myField: value }) before moving forward. The library carries context across the whole flow — it's the intended solution for both prop-drilling and state persistence.

Async Step Loading

Step loaders can be either:

  • Sync — a component reference (renders immediately, no loading flash)
  • Async — a function returning a promise, e.g. () => import("./HeavyStep") (resolved via React.lazy + Suspense)

The outlet's <Suspense> boundary shows the consumer-provided fallback prop while async steps load. Retreat is always sync because the previous step is already in the history stack.

Chrome

Chrome is stable UI that wraps the step — modal headers, progress indicators, close buttons. Chrome is provided as a render prop to <SequentOutlet chrome={...} />. It receives the step slot (error boundary + Suspense + active step) as an argument and returns JSX. Chrome stays mounted across async step transitions without flickering because it renders outside the Suspense boundary.

<SequentOutlet chrome={chrome}>   ← flow-level context (chrome + idle children)
{chrome(
<step-context> ← step-only context (active step only)
<FlowErrorBoundary>
<Suspense fallback={…}> ← loading state for async steps
<ActiveStep /> ← swaps on each transition
</Suspense>
</FlowErrorBoundary>
</step-context>
)}
// chrome: can call useSequentContext(), not useSequentStep()
// step: can call both useSequentStep() and useSequentContext()
</SequentOutlet>

Chrome reads flow state via useSequentContext(). Steps write chrome-relevant data into context via advance's contextPatch parameter.

Animated Transitions

By default, step swaps are immediate — the old step unmounts as the new step mounts. To animate transitions (crossfade, slide, or any enter/exit animation), provide a transition render prop to <SequentOutlet />.

The react-sequent/transitions subpath ships ready-made factories — crossfade() and slide() — that turn this into a one-liner. See the Transitions API page.

How It Works

When transition is present, the outlet enters a three-phase transition lifecycle on every step change:

  1. exiting — The outlet mounts both the old and new step. Play the exit animation, then call onExited().
  2. entering — The exiting step unmounts. The entering step renders alone for one tick so consumers can trigger an enter animation.
  3. exited — The flow is settled. No animation is in progress.

The render prop receives previousStep, nextStep, phase, onExited, and transitionKey — everything needed to construct an animated layout.

Step instances are retained across the transition: the outgoing step keeps its local state and effects while it animates out, and the incoming step is not remounted when the transition settles. The slots are lightweight placeholders backed by portals, so this holds no matter how your wrapper structure changes between phases.

Example: Crossfade

import { crossfade } from "react-sequent/transitions";

const transition = crossfade(); // 300ms, ease

<SequentOutlet transition={transition} />

See this exact crossfade live in the Subsection Flow demo, and slide({ fade: true }) in the Modal demo.

Writing a custom transition

The factories cover the common cases, but the transition prop stays a plain render prop — any hand-rolled harness works. A minimal CSS crossfade looks like this:

<SequentOutlet
transition={({ previousStep, nextStep, phase, onExited, transitionKey }) => {
if (phase === "exited") return nextStep;
return (
<div style={{ position: "relative" }}>
<div
key={`exit-${transitionKey}`}
style={{ position: "absolute", animation: "fadeOut 300ms" }}
onAnimationEnd={onExited}
>
{previousStep}
</div>
<div key={`enter-${transitionKey}`} style={{ animation: "fadeIn 300ms" }}>
{nextStep}
</div>
</div>
);
}}
/>

For a fully custom harness built on an animation library, see the Full-Screen Wizard demo (Motion).

transitionKey

transitionKey is a monotonically increasing integer that increments on every new exit transition. Attach it as a React key on animation wrapper elements to force React to remount them — otherwise back-to-back queued transitions reuse the same DOM node and CSS animations never restart.

Library Agnostic

The outlet doesn't know about CSS, framer-motion, GSAP, or any animation library. It only provides the wiring surface: both steps mounted, a phase state, a completion callback, and an identity key. Wire onExited to transitionend, onAnimationEnd, framer-motion's onExitComplete, or any other completion signal.

Constraints

  • A step may only initiate one transition. Subsequent advance() or retreat() calls from the same step render are silently dropped.
  • While in the exiting phase, the entering step may enqueue a navigation for after the current exit completes.
  • resolve() and abort() tear down both steps immediately — onExited is not expected.
  • Chrome wraps the transition output and is never unmounted during a transition.
  • Without the transition prop, behavior is identical to the current release.