Skip to main content

Recipe — Multi-Step Form (Single Process Wizard)

This recipe shows how to build a wizard-style form driven by a single process instance, where each step corresponds to a userTask and the user moves forward sequentially. Use the onTaskFlow helper to manage the lifecycle automatically.

The Process

[Step 1: Personal Info] ──→ [Step 2: Address] ──→ [Step 3: Review] ──→ [end]
userTask userTask userTask

Each step is a userTask with a formSchema and dataInput / dataOutput declarations that pass data between steps.

When to Use onTaskFlow

onTaskFlow is designed for flows where:

  • One user completes all the tasks sequentially (not a multi-user process)
  • The frontend acts as a wizard — one step at a time
  • You want the SDK to handle state transitions automatically

For multi-user flows (requester + approver), use raw events (work.snapshot, task.assigned) instead.

Backend: Create Session

const { session_token } = await fetch('https://api.stateway.io/v1/sessions', {
method: 'POST',
headers: { 'X-API-Key': process.env.STATEWAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: user.id,
user_display: user.name,
ttl: 3600,
scope: {
definitions: [{
key: 'onboarding-form',
roles: [],
candidate_as_user: false,
can_start: true,
max_concurrent: 1
}]
}
})
}).then(r => r.json());

Frontend: React Component

import { StatewayClient, TaskItem } from '@stateway/frontend-sdk';
import { useEffect, useState, useRef } from 'react';

function OnboardingWizard({ sessionToken }: { sessionToken: string }) {
const [currentTask, setCurrentTask] = useState<TaskItem | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState('');
const [notification, setNotification] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const swRef = useRef<StatewayClient | null>(null);

useEffect(() => {
const sw = new StatewayClient({
endpoint: 'wss://ws.stateway.io',
sessionToken,
});
swRef.current = sw;

const cleanup = sw.onTaskFlow({
onTask: (task) => {
setCurrentTask(task);
setIsProcessing(false);
setNotification(null);
},
onProcessing: (elementName) => {
setIsProcessing(true);
setProcessingStep(elementName);
setCurrentTask(null);
},
onNotification: (message) => {
setNotification(message);
},
onComplete: () => {
setIsComplete(true);
setCurrentTask(null);
setIsProcessing(false);
},
});

// Start the process on first connect when no instance exists
sw.on('work.empty', ({ definitions }) => {
const def = definitions.find(d => d.canStart && d.openInstances === 0);
if (def) sw.startProcess(def.key);
});

return () => {
cleanup();
sw.disconnect();
};
}, [sessionToken]);

async function handleStepSubmit(values: Record<string, unknown>) {
if (!currentTask || !swRef.current) return;
await swRef.current.completeTask(currentTask.taskId, values);
}

if (isComplete) return <div>Form submitted successfully!</div>;
if (isProcessing) return <div>Processing: {processingStep}...</div>;
if (!currentTask) return <div>Connecting...</div>;

return (
<div>
{notification && <div className="notification">{notification}</div>}
<h2>{currentTask.name}</h2>
<DynamicForm
schema={currentTask.formSchema}
initialValues={currentTask.variables}
onSubmit={handleStepSubmit}
/>
</div>
);
}

How onTaskFlow Works

onTaskFlow subscribes to the following internal events and maps them to your callbacks:

Internal eventCallback triggered
work.snapshot (with items) or task.assignedonTask
task.completed where next.kind is 'automatic' or 'timer'onProcessing
notificationonNotification
work.empty with no startable definitions (all instances closed)onComplete

The cleanup function returned by onTaskFlow removes all these subscriptions.

Key Points

  • max_concurrent: 1 prevents the user from starting multiple instances by accident
  • Use currentTask.variables to pre-fill form fields with data from previous steps (via dataInput declarations in the BPMN)
  • Notifications from intermediate throw events appear between steps via onNotification
  • onTaskFlow is a convenience wrapper — you can replicate its behavior with raw events if needed