Building a Resilient Form
Automate loading states, add error handling, and implement smart retries for a professional submission experience.
In this tutorial, we'll build a "Contact Us" form that doesn't just work — it feels premium. We'll use AsyncFlowState to handle all the "unhappy paths" that usually take hundreds of lines of boilerplate.
1. Setup
First, let's create a basic form with a useFlow hook. We'll assume you're using React, but this pattern works identically in Vue, Svelte, or SolidJS.
import { useFlow } from "@asyncflowstate/react";
function ContactForm() {
const flow = useFlow(async (data: FormData) => {
// Simulate a server request
await new Promise((r) => setTimeout(r, 800));
return await api.sendMessage(data);
});
return (
<form {...flow.form()}>
<input name="email" type="email" required />
<textarea name="message" required />
<button {...flow.button()}>
{flow.loading ? "Sending..." : "Send Message"}
</button>
{flow.error && (
<p ref={flow.errorRef} role="alert" className="error">
{flow.error.message}
</p>
)}
</form>
);
}What we just achieved:
- Automatic Loading: The button disables itself and shows "Sending..." during the request.
- Double-Click Prevention: The form won't submit twice if a user spam-clicks the button.
- Accessibility: ARIA attributes like
aria-busyandaria-disabledare managed for you. - Error Focus: If the server fails, the error message is automatically focused for screen readers via
flow.errorRef.
2. Adding Self-Healing Retries
Network requests are fragile. Let's add an Exponential Backoff retry policy to handle transient network errors (like flickering Wi-Fi).
const flow = useFlow(sendMessage, {
retry: {
maxAttempts: 3,
backoff: "exponential",
jitter: true,
},
onError: (err) => toast.error(`Failed after 3 retries: ${err.message}`),
});Why this is "Premium":
By using Jitter, we prevent "stampeding" our server if multiple clients fail at the same time. The library handles the math — you just set the flag.
3. UI Polish with minDuration
Fast servers can sometimes feel "jittery" if the loading spinner disappears too quickly. We can enforce a minimum stable duration for the loading state.
const flow = useFlow(sendMessage, {
retry: {
/* ... */
},
loading: {
minDuration: 400, // loading state stays visible for at least 400ms
},
});UX Tip
This prevents the "flicker" effect on fast connections, making your app feel more deliberate and stable.
4. Handling Field Validations
Often, server errors are specific to a field (e.g., "Email already exists"). AsyncFlowState can map these errors directly to your UI.
const flow = useFlow(sendMessage, {
// Map server errors to specific field IDs
onValidationError: (errors) => {
// ... handle custom logic if needed
},
});
return (
<form {...flow.form()}>
<input name="email" />
{flow.fieldErrors.email && (
<span className="error">{flow.fieldErrors.email}</span>
)}
{/* ... */}
</form>
);5. Final UX Summary
Your form now has:
- Zero-Boilerplate Loading: No manual
setLoadingstate. - Resilience: Smart retries with jitter and backoff.
- Stability: Guaranteed minimum loading time for a smoother UI.
- Accessibility: Industry-standard ARIA management.
- Focus Management: Automatic focus on error messages.
Next Steps
Learn how to use Optimistic UI to make this form feel even faster in the next tutorial.
