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 event | Callback triggered |
|---|---|
work.snapshot (with items) or task.assigned | onTask |
task.completed where next.kind is 'automatic' or 'timer' | onProcessing |
notification | onNotification |
work.empty with no startable definitions (all instances closed) | onComplete |
The cleanup function returned by onTaskFlow removes all these subscriptions.
Key Points
max_concurrent: 1prevents the user from starting multiple instances by accident- Use
currentTask.variablesto pre-fill form fields with data from previous steps (viadataInputdeclarations in the BPMN) - Notifications from intermediate throw events appear between steps via
onNotification onTaskFlowis a convenience wrapper — you can replicate its behavior with raw events if needed