Asynchronous workflows
Latest intent, request ownership, retry and retained drafts in a real application example.
Quick start
After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.
'use client';
import { useEffect, useRef, useState } from 'react';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
import { Stack } from '@fayegram-ai/faye-ui/layout/layout';
export function DraftForm({ save }: { save: (data: FormData, signal: AbortSignal) => Promise<void> }) {
const active = useRef<AbortController | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => () => active.current?.abort(), []);
return <form onSubmit={async event => {
event.preventDefault();
if (active.current) return;
const controller = new AbortController();
active.current = controller;
const data = new FormData(event.currentTarget);
setBusy(true); setError(undefined);
try { await save(data, controller.signal); }
catch { if (!controller.signal.aborted) setError('Could not save. Your draft is retained; try again.'); }
finally {
if (!controller.signal.aborted) { active.current = null; setBusy(false); }
}
}}>
<Stack gap="4">
<TextField name="title" label="Give your day a name" required error={error} />
<Button type="submit" pending={busy}>Save draft</Button>
<Button type="button" disabled={!busy} onClick={() => {
active.current?.abort(); active.current = null; setBusy(false);
}}>Cancel save</Button>
</Stack>
</form>;
}API reference: Text Field, Button.
The application owns asynchronous work
Faye UI controls provide labels, values, pending state and error relationships. Request timing, cancellation, duplicate submission and draft persistence belong to the application. The example below uses local delayed promises; it does not contact a server or represent a recovered production backend.
Latest intent and cleanup
Changing the search cancels the prior task. The intentionally slower old query cannot overwrite a later query. Unmount cancels pending search and save work before any late callback updates component state.
Failure, retry and drafts
Saving acquires synchronous request ownership before awaiting work, so repeated native submissions share one pending operation. A local unavailable-service failure keeps the controlled draft and connects its error to the field. Enabling the local service and retrying preserves the draft and records the submitted snapshot. The real documentation browser tests exercise the race, duplicate submit and retry in both rendering editions.
Applications must supply their own authenticated transport, request policy and persistence. Cancellation of local work does not promise a remote server has undone an accepted request.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Asynchronous application ownership
Local delayed requests demonstrate cancellation, retry and draft retention. No server is contacted.
Read the complete example
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { Checkbox } from '@fayegram-ai/faye-ui/controls/checkbox';
import { ControlLabel } from '@fayegram-ai/faye-ui/controls/field-layout';
import { Spinner } from '@fayegram-ai/faye-ui/feedback/loading';
import { Text } from '@fayegram-ai/faye-ui/core/text';
import { Stack, Inline } from '@fayegram-ai/faye-ui/layout/layout';
import { Section } from '../example';
// Local asynchronous fixture, not a backend or data engine. Ownership belongs to the app.
function delayed<T>(value: T, milliseconds: number, signal: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(new DOMException('Cancelled', 'AbortError')); };
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(value); }, milliseconds);
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
});
}
export default function AsyncWorkflows() {
const [query, setQuery] = useState(''), [result, setResult] = useState(''), [searching, setSearching] = useState(false);
const [draft, setDraft] = useState(''), [available, setAvailable] = useState(false), [pending, setPending] = useState(false);
const [error, setError] = useState<string>(), [saved, setSaved] = useState(''), [attempts, setAttempts] = useState(0);
const submission = useRef<AbortController | undefined>(undefined);
useEffect(() => {
const controller = new AbortController();
if (!query.trim()) { setResult(''); setSearching(false); return () => controller.abort(); }
setSearching(true);
delayed(query, query === 'old' ? 350 : 40, controller.signal).then(value => {
if (!controller.signal.aborted) { setResult(value); setSearching(false); }
}).catch(() => {}); // This fixture's only rejection is owned cancellation.
return () => controller.abort();
}, [query]);
useEffect(() => () => { submission.current?.abort(); submission.current = undefined; }, []);
return <Section title="Asynchronous application ownership" note="Local delayed requests demonstrate cancellation, retry and draft retention. No server is contacted.">
<Stack gap="4">
<TextField label="Search example records" value={query} onChange={event => setQuery(event.target.value)}/>
<Inline className="docs-async-status" role="status" aria-live="polite"><span className="docs-async-indicator">{searching && <Spinner size="sm" aria-hidden="true"/>}</span><span>{searching ? 'Searching…' : result ? `Latest result: ${result}` : 'Enter a search'}</span></Inline>
<form data-testid="async-draft-form" onSubmit={async event => {
event.preventDefault();
if (submission.current) return; // Synchronous ownership also guards duplicate native submits.
const controller = new AbortController(); submission.current = controller;
const submitted = draft, canSave = available;
setPending(true); setError(undefined); setAttempts(value => value + 1);
try {
await delayed(undefined, 250, controller.signal);
if (controller.signal.aborted) return;
if (!canSave) throw new Error('Service unavailable. Your draft is retained; enable the local service and retry.');
setSaved(submitted);
} catch (caught) {
if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : String(caught));
} finally {
if (submission.current === controller) { submission.current = undefined; setPending(false); }
}
}}>
<Stack gap="3">
<TextField label="Project draft" name="draft" required value={draft} error={error} onChange={event => setDraft(event.target.value)}/>
<ControlLabel label="Local service available">{attrs => <Checkbox.Root {...attrs} checked={available} onCheckedChange={setAvailable}><Checkbox.Indicator/></Checkbox.Root>}</ControlLabel>
<Inline><Button type="submit" pending={pending}>{error ? 'Retry save' : 'Save draft'}</Button></Inline>
<Text as="p" role="status" aria-live="polite" className="docs-async-status">{pending ? 'Saving…' : saved ? `Saved draft: ${saved}` : 'Draft not saved'}</Text>
<output aria-label="Save attempts">{attempts}</output>
</Stack>
</form>
</Stack>
</Section>;
}