Rendering Workflows
React components for rendering multi-step workflow interfaces.
Workflow UIs are assembled from the compound <Flow> component family. All components are headless: they ship a bare default (plain <button>, <ol>) and expose a render prop for full control. Import them from @rilaykit/workflow/react (or rilaykit/react).
Component Overview
| Component | Purpose |
|---|---|
<Flow> | Root wrapper. Accepts a config or builder, provides context. |
<Flow.Body> | Renders the current step's form (or custom renderer). |
<Flow.Progress> | Progress indicator across visible steps. |
<Flow.Next> | Validates the current step and advances (submits on the last step). |
<Flow.Back> | Navigates to the previous visible step. |
<Flow.Skip> | Skips the current step when allowed; renders nothing otherwise. |
<WorkflowProvider> | Internal provider used by <Flow>; exported for advanced composition. |
Each sub-component is also exported standalone (FlowBody, FlowProgress, FlowNext, FlowBack, FlowSkip).
Flow
Root component. of accepts a built WorkflowConfig or a flow builder instance — builders are resolved (and memoized) automatically.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
of | WorkflowConfig | flow | Yes | Workflow configuration or builder instance. |
children | React.ReactNode | Yes | Layout components. |
defaults | Record<string, unknown> | No | Initial data pre-populated across steps. |
defaultStep | string | No | Step ID to start on instead of the first step. |
onStepChange | (from: number, to: number, context: WorkflowContext) => void | No | Fired on every step transition. |
onComplete | (data, meta: WorkflowCompletionMeta) => void | Promise<void> | No | Fired when the last step is submitted. |
className | string | No | Forwarded to the underlying FormProvider wrapper. |
data contains only answered visible steps — skipped or never-visible steps are absent. meta is { visitedSteps, skippedSteps, passedSteps } (string[], insertion order); ignore it if you only need the data.
<Flow
of={onboardingFlow}
defaults={{ email: user.email }}
onComplete={async (data, meta) => {
await saveOnboarding(data);
if (meta.skippedSteps.length) trackSkips(meta.skippedSteps);
router.push('/dashboard');
}}
>
{/* Layout goes here */}
</Flow>Flow.Body
Renders the current step's content.
| Prop | Type | Required | Description |
|---|---|---|---|
stepId | string | No | Only render when the current step matches this ID. |
children | ReactNode | (ctx: { step: StepConfig }) => ReactNode | No | Fallback content or render prop. |
Precedence: the step's custom renderer(step) → children (render prop receives { step }) → <FormBody /> from @rilaykit/forms/react.
{/* Render any step */}
<Flow.Body />
{/* Custom layout for the "review" step only */}
<Flow.Body stepId="review">
<ReviewSummary />
</Flow.Body>When several <Flow.Body> components with stepId are in the tree, only the matching one produces output.
Flow.Progress
Headless progress indicator over visible steps — steps hidden by conditions are filtered out.
| Prop | Type | Required | Description |
|---|---|---|---|
children | (ctx: FlowStepsContext) => ReactNode | No | Render prop. Without it, renders an <ol data-flow-progress> of step titles with data-active on the current one. |
className | string | No | Applied to the default <ol>. |
interface FlowStepsContext {
steps: StepConfig[]; // Visible steps only
currentIndex: number; // Index within the visible steps
goTo: (visibleIndex: number) => void; // Maps back to the original step index
}<Flow.Progress>
{({ steps, currentIndex, goTo }) =>
steps.map((step, i) => (
<button key={step.id} aria-current={i === currentIndex} onClick={() => goTo(i)}>
{step.title}
</button>
))
}
</Flow.Progress>Flow.Next, Flow.Back, Flow.Skip
Navigation buttons sharing the same props and render-prop context:
| Prop | Type | Required | Description |
|---|---|---|---|
children | ReactNode | (ctx: FlowNavContext) => ReactNode | No | Custom label, or render prop for full control. |
className | string | No | Applied to the default <button>. |
interface FlowNavContext {
go: () => void; // Trigger the action
canGo: boolean; // false during transitions or submission (Back: also on first step)
submitting: boolean;
isLastStep: boolean;
step: StepConfig;
}Flow.Nextvalidates the current step's form, then advances — or completes the workflow on the last visible step.Flow.Backnavigates to the previous visible step (hidden steps are skipped over).Flow.Skipskips without validation; it rendersnullunless the step is skippable —allowSkip: true(or anallowSkippredicate returningtrue) or askippablecondition evaluating totrue.
The bare default is a plain <button data-flow-nav="next|back|skip"> with a "Next" / "Back" / "Skip" label:
<Flow.Next className="btn-primary" />
<Flow.Next>
{({ go, canGo, isLastStep, submitting }) => (
<Button onClick={go} disabled={!canGo} loading={submitting}>
{isLastStep ? 'Finish' : 'Continue'}
</Button>
)}
</Flow.Next>WorkflowProvider
The internal provider <Flow> wraps. Same props except workflowConfig (a built WorkflowConfig, not a builder), defaultValues, and onWorkflowComplete(data, meta). It manages the workflow Zustand store, FormProvider synchronization per step, persistence loading, condition evaluation, and analytics. Reach for it only for advanced composition; useFlow() reads its context.
Complete Example
import { Flow } from '@rilaykit/workflow/react';
function OnboardingPage() {
return (
<Flow
of={onboardingFlow}
onComplete={async (data) => {
await api.completeOnboarding(data);
}}
className="max-w-2xl mx-auto"
>
<Flow.Progress className="mb-8" />
<div className="min-h-[400px]">
<Flow.Body />
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<Flow.Back className="px-4 py-2" />
<div className="flex gap-3">
<Flow.Skip className="px-4 py-2 text-muted-foreground" />
<Flow.Next className="px-6 py-2" />
</div>
</div>
</Flow>
);
}