rilaykit
Workflow

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

ComponentPurpose
<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

PropTypeRequiredDescription
ofWorkflowConfig | flowYesWorkflow configuration or builder instance.
childrenReact.ReactNodeYesLayout components.
defaultsRecord<string, unknown>NoInitial data pre-populated across steps.
defaultStepstringNoStep ID to start on instead of the first step.
onStepChange(from: number, to: number, context: WorkflowContext) => voidNoFired on every step transition.
onComplete(data, meta: WorkflowCompletionMeta) => void | Promise<void>NoFired when the last step is submitted.
classNamestringNoForwarded 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.

PropTypeRequiredDescription
stepIdstringNoOnly render when the current step matches this ID.
childrenReactNode | (ctx: { step: StepConfig }) => ReactNodeNoFallback 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.

PropTypeRequiredDescription
children(ctx: FlowStepsContext) => ReactNodeNoRender prop. Without it, renders an <ol data-flow-progress> of step titles with data-active on the current one.
classNamestringNoApplied 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:

PropTypeRequiredDescription
childrenReactNode | (ctx: FlowNavContext) => ReactNodeNoCustom label, or render prop for full control.
classNamestringNoApplied 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.Next validates the current step's form, then advances — or completes the workflow on the last visible step.
  • Flow.Back navigates to the previous visible step (hidden steps are skipped over).
  • Flow.Skip skips without validation; it renders null unless the step is skippable — allowSkip: true (or an allowSkip predicate returning true) or a skippable condition evaluating to true.

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>
  );
}

On this page