Skip to content

Framework Integration

awaitly integrates naturally with popular frameworks. This guide shows common patterns for React, Next.js, Express, and Fastify.

Create a reusable hook for running workflows in components:

import { useState, useCallback } from 'react';
import { type Result, type UnexpectedError } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
type WorkflowState<T, E> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E | UnexpectedError };
export function useWorkflow<T, E, Deps extends Record<string, unknown>>(
deps: Deps,
workflowFn: (step: any, deps: Deps) => Promise<T>
) {
const [state, setState] = useState<WorkflowState<T, E>>({ status: 'idle' });
const run = useCallback(async () => {
setState({ status: 'loading' });
const workflow = createWorkflow('workflow', deps);
const result = await workflow.run(workflowFn);
if (result.ok) {
setState({ status: 'success', data: result.value });
} else {
setState({ status: 'error', error: result.error as E | UnexpectedError });
}
return result;
}, [deps, workflowFn]);
return { ...state, run };
}

Usage in a component:

function CheckoutButton({ cartId }: { cartId: string }) {
const { status, data, error, run } = useWorkflow(
{ validateCart, processPayment, sendConfirmation },
async ({ step, deps }) => {
const cart = await step('validateCart', () => deps.validateCart(cartId));
const payment = await step('processPayment', () => deps.processPayment(cart.total));
await step('sendConfirmation', () => deps.sendConfirmation(cart.email, payment.id));
return { orderId: payment.id };
}
);
return (
<div>
<button onClick={run} disabled={status === 'loading'}>
{status === 'loading' ? 'Processing...' : 'Checkout'}
</button>
{status === 'error' && <p>Error: {String(error)}</p>}
{status === 'success' && <p>Order confirmed: {data.orderId}</p>}
</div>
);
}

Integrate with React error boundaries for unexpected errors:

import { Component, type ReactNode } from 'react';
import { isUnexpectedError } from 'awaitly';
interface Props {
children: ReactNode;
fallback: (error: unknown) => ReactNode;
}
interface State {
error: unknown | null;
}
export class WorkflowErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: unknown): State {
return { error };
}
render() {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<WorkflowErrorBoundary
fallback={(error) => (
<div>
{isUnexpectedError(error)
? 'An unexpected error occurred'
: 'Something went wrong'}
</div>
)}
>
<CheckoutPage />
</WorkflowErrorBoundary>
);
}

Use awaitly in Next.js 13+ server actions:

app/actions/checkout.ts
'use server';
import { type AsyncResult } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
import { validateCart, processPayment, sendEmail } from '@/lib/services';
type CheckoutResult = { orderId: string };
type CheckoutError = 'INVALID_CART' | 'PAYMENT_FAILED' | 'EMAIL_FAILED';
export async function checkout(
cartId: string
): Promise<AsyncResult<CheckoutResult, CheckoutError>> {
const workflow = createWorkflow('workflow', { validateCart,
processPayment,
sendEmail,
});
return await workflow.run(async ({ step, deps }) => {
const cart = await step('validateCart', () => deps.validateCart(cartId));
const payment = await step('processPayment', () => deps.processPayment(cart.total));
await step('sendEmail', () => deps.sendEmail(cart.email, payment.receiptUrl));
return { orderId: payment.id };
});
}

Use in a client component:

'use client';
import { checkout } from './actions/checkout';
export function CheckoutForm({ cartId }: { cartId: string }) {
const [pending, setPending] = useState(false);
async function handleSubmit() {
setPending(true);
const result = await checkout(cartId);
setPending(false);
if (result.ok) {
redirect(`/order/${result.value.orderId}`);
} else {
// Handle typed errors
switch (result.error) {
case 'INVALID_CART':
toast.error('Your cart is invalid');
break;
case 'PAYMENT_FAILED':
toast.error('Payment failed, please try again');
break;
case 'EMAIL_FAILED':
toast.warning('Order placed but confirmation email failed');
break;
}
}
}
return (
<button onClick={handleSubmit} disabled={pending}>
{pending ? 'Processing...' : 'Complete Order'}
</button>
);
}

Use awaitly in Next.js API routes:

app/api/orders/route.ts
import { NextResponse } from 'next/server';
import { createWorkflow } from 'awaitly/workflow';
export async function POST(request: Request) {
const body = await request.json();
const workflow = createWorkflow('workflow', { validateOrder, chargeCard, createOrder });
const result = await workflow.run(async ({ step, deps }) => {
const validated = await step('validateOrder', () => deps.validateOrder(body));
const charge = await step('chargeCard', () => deps.chargeCard(validated.amount));
const order = await step('createOrder', () => deps.createOrder(validated, charge.id));
return order;
});
if (result.ok) {
return NextResponse.json(result.value, { status: 201 });
}
// Map errors to HTTP responses
const errorMap: Record<string, number> = {
VALIDATION_ERROR: 400,
CARD_DECLINED: 402,
INVENTORY_ERROR: 409,
};
const status = errorMap[String(result.error)] ?? 500;
return NextResponse.json({ error: result.error }, { status });
}

Create middleware for consistent error handling:

import express, { type Request, type Response, type NextFunction } from 'express';
import { type Result, isUnexpectedError } from 'awaitly';
// Middleware to handle Result responses
function resultHandler<T, E>(
handler: (req: Request) => Promise<Result<T, E>>
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await handler(req);
if (result.ok) {
res.json(result.value);
} else {
// Map domain errors to HTTP status codes
const error = result.error;
if (isUnexpectedError(error)) {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(400).json({ error });
}
}
} catch (error) {
next(error);
}
};
}
// Usage
const app = express();
app.post('/api/orders', resultHandler(async (req) => {
const workflow = createWorkflow('workflow', { validateOrder, processPayment });
return await workflow.run(async ({ step, deps }) => {
const order = await step('validateOrder', () => deps.validateOrder(req.body));
const payment = await step('processPayment', () => deps.processPayment(order.total));
return { orderId: payment.id };
});
}));
import { type AsyncResult } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
import { Router } from 'express';
const router = Router();
// POST /users
router.post('/users', async (req, res) => {
const workflow = createWorkflow('workflow', { validateUser, createUser, sendWelcome });
const result = await workflow.run(async ({ step, deps }) => {
const validated = await step('validateUser', () => deps.validateUser(req.body));
const user = await step('createUser', () => deps.createUser(validated));
await step('sendWelcome', () => deps.sendWelcome(user.email));
return user;
});
if (result.ok) {
return res.status(201).json(result.value);
}
// Type-safe error handling
const error = result.error;
switch (error) {
case 'INVALID_EMAIL':
case 'WEAK_PASSWORD':
return res.status(400).json({ error, message: 'Validation failed' });
case 'USER_EXISTS':
return res.status(409).json({ error, message: 'User already exists' });
case 'EMAIL_FAILED':
// User created but email failed - still success
return res.status(201).json({
...result.value,
warning: 'Welcome email could not be sent'
});
default:
return res.status(500).json({ error: 'Internal error' });
}
});
import Fastify from 'fastify';
import { type Result } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
const fastify = Fastify();
// Decorate request with workflow helper
fastify.decorateRequest('workflow', null);
fastify.addHook('preHandler', async (request) => {
request.workflow = <T, E, Deps extends Record<string, unknown>>(
deps: Deps,
fn: (step: any, deps: Deps) => Promise<T>
) => {
const workflow = createWorkflow('workflow', deps);
return workflow.run(fn);
};
});
// Route using the helper
fastify.post('/orders', async (request, reply) => {
const result = await request.workflow.run(
{ validateOrder, processPayment },
async ({ step, deps }) => {
const order = await step('validateOrder', () => deps.validateOrder(request.body));
const payment = await step('processPayment', () => deps.processPayment(order.total));
return { orderId: payment.id };
}
);
if (result.ok) {
return reply.code(201).send(result.value);
}
return reply.code(400).send({ error: result.error });
});
import { Type } from '@sinclair/typebox';
const OrderSchema = Type.Object({
items: Type.Array(Type.Object({
productId: Type.String(),
quantity: Type.Number(),
})),
shippingAddress: Type.String(),
});
fastify.post('/orders', {
schema: {
body: OrderSchema,
},
}, async (request, reply) => {
// Body is already validated by Fastify
const result = await createWorkflow('workflow', { processOrder }).run(async ({ step, deps }) => {
return await step('processOrder', () => deps.processOrder(request.body));
});
if (result.ok) {
return reply.send(result.value);
}
// Only domain errors at this point (validation already passed)
return reply.code(422).send({ error: result.error });
});

Create consistent error-to-HTTP mappings:

lib/error-mapper.ts
export const httpErrorMap = new Map<string, { status: number; message: string }>([
['NOT_FOUND', { status: 404, message: 'Resource not found' }],
['UNAUTHORIZED', { status: 401, message: 'Authentication required' }],
['FORBIDDEN', { status: 403, message: 'Access denied' }],
['VALIDATION_ERROR', { status: 400, message: 'Invalid input' }],
['RATE_LIMITED', { status: 429, message: 'Too many requests' }],
]);
export function mapErrorToResponse(error: unknown) {
const mapped = httpErrorMap.get(String(error));
return mapped ?? { status: 500, message: 'Internal server error' };
}

Pass request context through workflows:

const workflow = createWorkflow('workflow', { fetchUser, logAction });
const result = await workflow.run(
async ({ step, deps, ctx }) => {
const user = await step('fetchUser', () => deps.fetchUser(ctx.userId));
await step('logAction', () => deps.logAction(ctx.requestId, 'user_fetched'));
return user;
},
{
createContext: () => ({
userId: req.user.id,
requestId: req.headers['x-request-id'],
}),
}
);