Atlas Forms
Writing a Custom Action Handler
A custom action handler is an async function that receives FormActionContext and must call either complete() or fail() before returning. This page covers the handler signature, async patterns, error handling, and common pitfalls.
Handler Signature
import type { FormActionContext } from '@atlas-forms/types-js';
// The required function signature
type FormActionHandler = (context: FormActionContext) => Promise<void>;
// Minimum valid handler
const myHandler: FormActionHandler = async (ctx) => {
// ... do work ...
ctx.complete();
};
Full Handler with Error Handling
import type { FormActionContext } from '@atlas-forms/types-js';
import { approvalService } from '../services/approvalService';
export const approveWorkflowHandler: FormActionHandler = async (ctx) => {
// 1. Validate first if needed
const isValid = await ctx.validate();
if (!isValid) {
ctx.fail('Please fix form errors before approving.');
return;
}
// 2. Extract values
const { formValues, tenantId, config } = ctx;
const workflowInstanceId = formValues['workflow-instance-id'];
const approverNotes = formValues['approver-notes'];
const transitionTo = config?.transitionTo ?? 'approved';
// 3. Guard — ensure we have required data
if (!workflowInstanceId) {
ctx.fail('No workflow ID found. Cannot approve.');
return;
}
try {
// 4. Call your service
await approvalService.approve({
workflowInstanceId,
tenantId,
notes: approverNotes,
status: transitionTo
});
// 5. Optionally update a field in the form to reflect new status
ctx.formEngine.setFieldValue('approval-status', transitionTo);
// 6. Signal success and navigate away
ctx.complete('Workflow approved successfully.');
ctx.navigate('/workdesk/inbox');
} catch (error) {
// 7. Always call fail() so the button resets
const message = error instanceof Error ? error.message : 'Unknown error';
ctx.fail(`Approval failed: ${message}`);
}
};
Async Patterns
Sequential Async Calls
const handler: FormActionHandler = async (ctx) => {
const step1 = await serviceA.doThing(ctx.formValues);
const step2 = await serviceB.doOther(step1.result);
ctx.complete(`Done: ${step2.summary}`);
};
Parallel Async Calls
const handler: FormActionHandler = async (ctx) => {
const [result1, result2] = await Promise.all([
serviceA.save(ctx.formValues),
serviceB.notify(ctx.tenantId, ctx.formId)
]);
ctx.complete();
};
Conditional Async
const handler: FormActionHandler = async (ctx) => {
const { config, formValues } = ctx;
if (config?.requireApproval) {
await approvalQueue.enqueue(formValues);
ctx.complete('Sent for approval');
} else {
await directSubmit(formValues);
ctx.complete('Submitted directly');
}
};
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Not calling complete() or fail() | Button stuck in loading state forever | Always call one in every code path |
| Calling complete() after navigate() | May try to update unmounted component | Call navigate() after complete() |
| Throwing instead of calling fail() | Generic error shown, no message | Catch errors and call fail(message) |
| Using formValues after engine changes | Stale data | Use ctx.formEngine.getValues() for fresh snapshot |