Every serious form implementation eventually collides with the same set of production failures: a validation callback fires on an already-unmounted component, an async uniqueness check resolves for a field the user has already changed, SSR markup diverges from client state during hydration and breaks ARIA attributes, or a subscription leak silently grows memory across SPA navigation. None of these are bugs in a specific library β they are architectural gaps in how form state crosses the boundary between a frameworkβs reactivity model and the generic logic that drives validation, submission, and error display.
This guide covers the adapter and hook patterns that prevent those failures. It assumes you are debugging a production system, not building your first form.
The Architecture Problem: Reactivity Models Diverge, Contracts Must Not
Reactβs useState and useReducer, Vue 3βs ref/reactive proxies, and Svelteβs writable stores are fundamentally different reactivity primitives. A validation pipeline written directly against one of them is not portable and cannot be unit-tested independently of the rendering engine.
The solution is a typed FormStateAdapter interface that every framework implementation satisfies. The adapter owns the translation between native reactivity and a stable public contract. Business logic β form validation lifecycle orchestration, dirty and pristine state tracking, and error state mapping β operates against the interface, not the framework.
export interface FormStateAdapter<T extends Record<string, unknown>> {
/** Read current snapshot β cheap, synchronous, no side effects. */
getState: () => FormSnapshot<T>;
/** Update a single field value. Pass shouldValidate to trigger async pipeline. */
setValue: (field: keyof T, value: unknown, shouldValidate?: boolean) => void;
/** Run the full validation pipeline. Resolves to an error map (empty on success). */
validate: () => Promise<Record<keyof T, string | undefined>>;
/**
* Reset to initial values.
* 'shallow' reverts top-level fields; nested object references are preserved.
* 'deep' clones the initial payload, clearing all mutation history and caches.
*/
reset: (strategy: 'shallow' | 'deep') => void;
/** Subscribe to state changes. Returns an unsubscribe function β always call it. */
subscribe: (listener: (state: FormSnapshot<T>) => void) => () => void;
/**
* Teardown: abort in-flight requests, clear timers, remove listeners.
* Must be called on component unmount / route change.
*/
destroy: () => void;
}
export type FormSnapshot<T extends Record<string, unknown>> = {
values: T;
/** Lifecycle phase β drives UI chrome (spinner, disabled submit, error summary). */
phase: FormPhase;
errors: Partial<Record<keyof T, string>>;
/** Per-field dirty flag β set on first user-driven change, cleared by reset. */
dirtyFields: Partial<Record<keyof T, boolean>>;
};
export type FormPhase =
| { status: 'idle' }
| { status: 'validating'; field?: string }
| { status: 'submitting' }
| { status: 'success' }
| { status: 'error'; reason: string };
The phase discriminated union is the most important part. When UI components subscribe to it they can exhaustively switch on status β the TypeScript compiler enforces that every state is handled. A plain boolean like isSubmitting cannot represent the full state space and leads to impossible UI combinations (e.g. isSubmitting && isSuccess both true).
State Machine Overview
The diagram below shows the lifecycle transitions that every adapter implementation must honour. The DIRTY and PRISTINE labels describe the field-level mutation flag rather than a top-level phase; they coexist with the submission phase.
The adapterβs phase field always reflects exactly one of these states. Components that render loading indicators, disabled submit buttons, or error summaries read phase.status rather than deriving it from multiple boolean flags.
React Hook Architecture
A React implementation of FormStateAdapter lives in a useForm hook that wraps useReducer for synchronous state transitions and useCallback/useRef for stable function references. Full patterns for composing field-level hooks, selector memoisation, and performance boundaries are covered in the React form hook architecture guide, including the custom useFormField hook for field-level subscription.
The key decision is keeping the reducer pure β no side effects inside dispatch β and pushing async validation into useEffect with an AbortController per field:
import { useReducer, useEffect, useRef, useCallback } from 'react';
import type { FormStateAdapter, FormSnapshot, FormPhase } from './adapter';
type Action<T> =
| { type: 'SET_VALUE'; field: keyof T; value: unknown }
| { type: 'SET_PHASE'; phase: FormPhase }
| { type: 'SET_ERRORS'; errors: Partial<Record<keyof T, string>> }
| { type: 'RESET'; strategy: 'shallow' | 'deep'; initial: T };
function formReducer<T extends Record<string, unknown>>(
state: FormSnapshot<T>,
action: Action<T>
): FormSnapshot<T> {
switch (action.type) {
case 'SET_VALUE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
dirtyFields: { ...state.dirtyFields, [action.field]: true },
};
case 'SET_PHASE':
return { ...state, phase: action.phase };
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'RESET':
return {
values: action.strategy === 'deep'
? structuredClone(action.initial)
: { ...action.initial },
phase: { status: 'idle' },
errors: {},
dirtyFields: {},
};
default:
return state;
}
}
export function useForm<T extends Record<string, unknown>>(
initialValues: T,
asyncValidate: (values: T, signal: AbortSignal) => Promise<Partial<Record<keyof T, string>>>
) {
const [state, dispatch] = useReducer(formReducer<T>, {
values: initialValues,
phase: { status: 'idle' },
errors: {},
dirtyFields: {},
});
// Stable ref so the AbortController cleanup captures the latest controller.
const abortRef = useRef<AbortController | null>(null);
const setValue = useCallback((field: keyof T, value: unknown, shouldValidate = false) => {
dispatch({ type: 'SET_VALUE', field, value });
if (shouldValidate) {
// Abort any in-flight validation for this field immediately.
abortRef.current?.abort();
abortRef.current = new AbortController();
}
}, []);
// Teardown on unmount β prevents stale dispatch after component removal.
useEffect(() => {
return () => { abortRef.current?.abort(); };
}, []);
return { state, setValue };
}
Stale dispatch calls after unmount are one of the most common sources of React console warnings. The cleanup function in useEffect guarantees that AbortController.abort() fires before React destroys the component, preventing the async validator from dispatching into a dead reducer.
Vue Composition API Adapters
Vue 3βs ref and reactive are synchronous and deeply tracked, which makes them a natural fit for form state β but that same deep tracking becomes a liability when validation logic mutates nested objects, triggering cascading watcher re-runs.
The adapter pattern for Vue isolates mutation to a single reactive store while exposing computed read-only selectors to template consumers. Vue Composition API form adapters covers the full pattern, and syncing Vue form state with Pinia addresses how to lift ephemeral form state into a shared store without triggering cross-component re-renders.
import { reactive, readonly, computed, onUnmounted } from 'vue';
import type { FormSnapshot, FormPhase } from './adapter';
export function useVueFormAdapter<T extends Record<string, unknown>>(initialValues: T) {
// Internal mutable store β never expose this directly to templates.
const _state = reactive<FormSnapshot<T>>({
values: { ...initialValues } as T,
phase: { status: 'idle' },
errors: {},
dirtyFields: {},
});
// AbortController stored outside reactive() β no need to track it.
let activeController: AbortController | null = null;
const setValue = (field: keyof T, value: unknown) => {
(_state.values as Record<keyof T, unknown>)[field] = value;
(_state.dirtyFields as Record<keyof T, boolean>)[field] = true;
};
const setPhase = (phase: FormPhase) => { _state.phase = phase; };
// Expose only readonly β prevents template code from bypassing the adapter contract.
const state = readonly(_state);
const isDirty = computed(() =>
Object.values(_state.dirtyFields).some(Boolean)
);
// Teardown registered automatically when the composable is used inside setup().
onUnmounted(() => { activeController?.abort(); });
return { state, isDirty, setValue, setPhase };
}
The readonly() wrapper is important: it turns runtime mutations into TypeScript errors, so templates cannot accidentally bypass setValue and write directly to _state.values. This preserves the single-source-of-truth invariant that makes the adapter testable in isolation.
Svelte Store Integration
Svelteβs compile-time reactivity requires a different approach. Rather than wrapping component lifecycle hooks, form state lives in a plain writable store that can be imported anywhere β including server-side rendering contexts. Svelte store integration for forms covers the full store shape, including how Svelte 5 runes change the subscription model.
import { writable, derived, get } from 'svelte/store';
import { onDestroy } from 'svelte';
import type { FormSnapshot } from './adapter';
export function createSvelteFormStore<T extends Record<string, unknown>>(initialValues: T) {
const _store = writable<FormSnapshot<T>>({
values: { ...initialValues } as T,
phase: { status: 'idle' },
errors: {},
dirtyFields: {},
});
// AbortController held outside the store β stores should hold serialisable data.
let controller: AbortController | null = null;
const setValue = (field: keyof T, value: unknown) => {
_store.update(s => ({
...s,
values: { ...s.values, [field]: value },
dirtyFields: { ...s.dirtyFields, [field]: true },
}));
};
// derived() is cheap β recomputes only when the upstream store emits.
const isDirty = derived(_store, $s => Object.values($s.dirtyFields).some(Boolean));
const destroy = () => {
controller?.abort();
// Svelte stores have no built-in destroy β the consumer must call this.
};
// When used inside a Svelte component, register teardown automatically.
try {
onDestroy(destroy);
} catch {
// Called outside component context (e.g. module-level) β caller must call destroy().
}
return { subscribe: _store.subscribe, isDirty, setValue, destroy };
}
The try/catch around onDestroy handles the common pattern of creating a store at module level for cross-component sharing. In that case the caller is responsible for calling destroy() β the store signals this by documenting it rather than silently swallowing the teardown.
SSR Hydration Sync
Server-rendered forms require the client-side adapter to initialise from the same values the server used to render the markup. Without this synchronisation, React, Vue, and Svelte all replace the server-rendered DOM on mount β resetting scroll position, losing focus, and triggering spurious dirty flags. Hydration sync for SSR forms covers the full pattern, including handling Svelte form hydration mismatches.
The reliable approach is to embed initial values as a JSON payload in the server-rendered HTML and read them before the adapter initialises:
/** Read server-rendered initial values from a <script> tag with data-form-init. */
export function readServerInitialValues<T>(formId: string, fallback: T): T {
if (typeof document === 'undefined') return fallback; // SSR context β use fallback.
const el = document.querySelector<HTMLScriptElement>(
`script[data-form-init="${formId}"]`
);
if (!el?.textContent) return fallback;
try {
return JSON.parse(el.textContent) as T;
} catch {
console.warn(`[form:${formId}] Failed to parse server initial values β using fallback.`);
return fallback;
}
}
The server renders:
<script type="application/json" data-form-init="checkout">
{"email":"[email protected]","country":"GB"}
</script>
The client passes readServerInitialValues('checkout', defaultValues) as the initialValues argument to the adapter. The hydrated DOM then matches the server output exactly.
Error Propagation & Accessibility
Validation errors are useless unless they reach assistive technology. The adapterβs error map drives three ARIA attributes on every field:
aria-invalid="true"signals the field is in an error state.aria-describedbypoints to the element containing the error message.aria-live="polite"on the error container ensures screen readers announce new messages without interrupting ongoing speech.
/** Apply ARIA attributes derived from the adapter's error map to a field element. */
export function syncFieldAria(
input: HTMLElement,
errorContainer: HTMLElement,
errorMessage: string | undefined
): void {
if (errorMessage) {
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', errorContainer.id);
errorContainer.textContent = errorMessage;
errorContainer.removeAttribute('hidden');
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
errorContainer.textContent = '';
errorContainer.setAttribute('hidden', '');
}
}
Three rules that must never be violated:
- Never rely solely on colour to communicate an error β always pair a colour change with an icon, text, or ARIA attribute change.
- Set
aria-invalidon the input element, not on a wrapper<div>β assistive technology reads it from the interactive element. - Do not remove
aria-describedbywhile the error container is still visible. Remove it and hide the container atomically (as the snippet above does).
Error state mapping patterns and mapping validation errors to UI components cover how to normalise validation library outputs (Zod, Yup, Valibot) into the flat Record<keyof T, string> map the adapter expects.
Validation Pipeline with Race Condition Guards
Async validators are the most common source of race conditions in form implementations. The pattern below combines debounce, AbortController for network-level cancellation, and sequence IDs for non-cancellable validators:
type SyncValidator<T> = (values: T) => Array<{ path: keyof T; message: string }>;
type AsyncValidator<T> = (
values: T,
signal: AbortSignal
) => Promise<Partial<Record<keyof T, string>> | null>;
export function createValidationPipeline<T extends Record<string, unknown>>(
sync: SyncValidator<T>,
async: AsyncValidator<T>[],
debounceMs = 300
) {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let controller: AbortController | null = null;
// Sequence ID prevents a slow validator resolving after a faster subsequent call.
let seq = 0;
return (values: T): Promise<Partial<Record<keyof T, string>>> => {
// Cancel any debounced or in-flight async validation.
if (debounceTimer) clearTimeout(debounceTimer);
controller?.abort();
// Run synchronous validators immediately β no network round-trip.
const syncErrors = sync(values);
if (syncErrors.length > 0) {
return Promise.resolve(
Object.fromEntries(syncErrors.map(e => [e.path, e.message]))
) as Promise<Partial<Record<keyof T, string>>>;
}
const currentSeq = ++seq;
controller = new AbortController();
const { signal } = controller;
return new Promise(resolve => {
debounceTimer = setTimeout(async () => {
// A newer call has superseded this one β discard the result.
if (seq !== currentSeq) return;
const results = await Promise.allSettled(async.map(fn => fn(values, signal)));
// Ignore if aborted or superseded between debounce and resolution.
if (seq !== currentSeq || signal.aborted) return;
const errors = results
.filter(
(r): r is PromiseFulfilledResult<Partial<Record<keyof T, string>> | null> =>
r.status === 'fulfilled' && r.value !== null
)
.reduce(
(acc, r) => ({ ...acc, ...r.value }),
{} as Partial<Record<keyof T, string>>
);
resolve(errors);
}, debounceMs);
});
};
}
The dual guard β seq !== currentSeq || signal.aborted β prevents two independent failure modes: the sequence check stops stale non-cancellable validators from overwriting newer results; the signal.aborted check stops resolved fetch responses from being applied after the controller was replaced.
This pattern connects directly to the asynchronous validation strategies guide, which covers debounce tuning, retry policies, and server-side rate limit handling.
Lifecycle Teardown Checklist
Teardown bugs are silent β they do not throw errors, they accumulate. Every adapter implementation must cover the following, in this order:
- Abort in-flight requests. Call
AbortController.abort()on any controller created during the adapterβs lifetime. Store controllers in aSetif multiple concurrent validators can run. - Clear debounce timers. Call
clearTimeouton every pending timer. A timer that fires after unmount will dispatch into dead state. - Unsubscribe store listeners. Svelte store subscriptions return an unsubscribe function β call it. Pinia
$subscribecallbacks return the same. VuewatchandwatchEffectreturn stop handles. - Remove DOM event listeners. Any listeners attached with
addEventListenerin the hook must be removed withremoveEventListenerusing the exact same function reference. - Expose
destroy()on the adapter interface. Framework lifecycle hooks (useEffectreturn,onUnmounted,onDestroy) call it automatically when the adapter is used inside a component. Module-level adapter instances require the caller to invokedestroy()manually on route change.
/** Minimal teardown registry β attach to every adapter instance. */
class TeardownRegistry {
private controllers = new Set<AbortController>();
private timers = new Set<ReturnType<typeof setTimeout>>();
private callbacks = new Set<() => void>();
addController(c: AbortController) { this.controllers.add(c); return c; }
addTimer(t: ReturnType<typeof setTimeout>) { this.timers.add(t); return t; }
addCallback(fn: () => void) { this.callbacks.add(fn); return fn; }
destroy() {
this.controllers.forEach(c => c.abort());
this.timers.forEach(t => clearTimeout(t));
this.callbacks.forEach(fn => fn());
this.controllers.clear();
this.timers.clear();
this.callbacks.clear();
}
}
The registry pattern scales to adapters that manage multiple fields with independent debounce timers and abort controllers β a common situation in large forms with 10+ async-validated fields.
Common Pitfalls
Tying validation directly to UI event handlers. When onChange fires validation logic inline, every keystroke can trigger a re-render cycle in the parent component. Extract validation into the adapterβs pipeline and let the component only call setValue.
Not debouncing async validators. A 50ms keystroke interval against a remote uniqueness check translates to hundreds of parallel requests per form session. Always debounce with a minimum of 250ms; 400ms is typical for email/username checks.
Shallow resets that miss nested state. When reset('shallow') is called on a form with nested objects, child object references remain pointing to mutated values. Use structuredClone for deep resets, or track nested mutations explicitly.
Discarding AbortController without aborting. Assigning a new controller to a variable without calling .abort() on the previous one leaks the pending request and can cause stale resolution. Always abort before replacing.
Global form adapter state in a module singleton. Sharing one adapter instance across multiple form instances via a module-level variable means reset operations and error states bleed between them. Each form instance must own its adapter instance.
Ignoring dirty and pristine state tracking for programmatic updates. When code calls setValue to populate fields (e.g. autofill, address lookup), the dirty flag should not be set. Distinguish programmatic mutations from user-driven ones by adding a source: 'user' | 'programmatic' argument to setValue.
Setting ARIA attributes only on blur. Screen readers navigate by field without triggering blur events. Set aria-invalid and aria-describedby on every validation state change, not only when the user leaves the field.
Frequently Asked Questions
What is the difference between shallow and deep reset strategies?
Shallow reset copies only the top-level keys of the initial values object back to the current state, leaving nested object references in place. This is fast but incorrect if nested objects were mutated. Deep reset uses structuredClone (or a recursive clone) to produce a fully independent copy of the initial payload, clearing all mutation history, async pending flags, and validation caches.
When should async validators use AbortController vs sequence IDs?
AbortController cancels in-flight fetch requests at the network level and is the correct default for HTTP-based validators. Sequence IDs are a lightweight fallback when using third-party SDK clients that do not expose a cancellation token β increment a counter on each call and discard results whose sequence number does not match the latest. Use both for belt-and-suspenders correctness, as the code sample above demonstrates.
How do custom hooks improve form validation architecture?
They encapsulate validation pipelines, state transitions, and error mapping into reusable composables. This separates business logic from rendering, enabling deterministic unit tests without a DOM, easier migration between framework versions, and simpler composition of cross-field dependency rules.
How should a design system expose form primitives across React, Vue, and Svelte?
Define the FormStateAdapter interface in a framework-agnostic package. Each framework adapter implements it, and design system components accept an adapter instance as a prop or composable argument. UI components call adapter.getState() and adapter.setValue() β they do not know which framework is underneath.
Related
- React Form Hook Architecture
- Vue Composition API Form Adapters
- Svelte Store Integration for Forms
- Hydration Sync for SSR Forms
β Home