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
| Term | Meaning |
|---|---|
| Flow | A sequence of steps managed as a unit |
| Step | A discrete state in a flow; owns its own transition logic |
| Advance | Move to the next step, as determined by the current step's logic |
| Retreat | Move to the previous step by popping the history stack |
| Context | Flow-scoped data the library carries and makes available to all steps |
| Outlet | The mount point where the active step renders (<SequentOutlet />) |
| Chrome | Stable UI (headers, progress bars) rendered inside the outlet but outside the step |
| Resolve | A flow completes successfully, returning a value |
| Abort | A 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
initto 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, andcontext - Has no access to initializer-level capabilities
useSequentContext — for chrome components
- Returns the current flow context value, plus
resolveandabortfor flow termination - Available to any component inside the outlet's flow-level provider boundary (chrome, idle children, and steps)
- Has no navigation capabilities — cannot
advanceorretreat
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 viaReact.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:
exiting— The outlet mounts both the old and new step. Play the exit animation, then callonExited().entering— The exiting step unmounts. The entering step renders alone for one tick so consumers can trigger an enter animation.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()orretreat()calls from the same step render are silently dropped. - While in the
exitingphase, the entering step may enqueue a navigation for after the current exit completes. resolve()andabort()tear down both steps immediately —onExitedis not expected.- Chrome wraps the transition output and is never unmounted during a transition.
- Without the
transitionprop, behavior is identical to the current release.