The split between controlled and uncontrolled inputs is one of the earliest architectural commitments in a form system — and one of the costliest to reverse mid-lifecycle. This page maps the state ownership model for each paradigm, shows where validation pipelines diverge, and provides a production-ready TypeScript adapter that unifies both under a single validation contract.
Problem Statement
You are building a form with mixed complexity: some fields need real-time cross-field validation (password / confirm-password, promo code that unlocks a discount tier), while others are large file inputs or third-party widgets whose DOM the framework must not own. Picking one paradigm globally means either over-rendering on every keystroke or losing the simplicity of framework-managed state. The failure modes look like:
- Stale DOM reads: an uncontrolled field’s
ref.current.valuereturns the previous value because a programmatic reset did not propagate. - Hydration mismatches: an SSR server renders a controlled input value that the client’s uncontrolled ref did not pick up, causing React’s reconciler to warn and potentially blank the field.
- Validation desync: a validator fires against React state for some fields and against the DOM for others, producing contradictory error messages when both are displayed at once.
The pattern that resolves all three is an explicit adapter that declares which fields belong to which paradigm and centralises value extraction.
State Machine: Field Ownership Lifecycle
Each field in a hybrid form moves through a defined ownership lifecycle. The diagram below shows the states for a single field and the transitions that shift it between controlled, uncontrolled, and error modes.
The ownership decision (controlled vs. uncontrolled) is fixed at mount time per field. Transitions between validation states happen identically for both paradigms — only the value-extraction mechanism differs.
State Ownership & Memory Allocation
Controlled components route every keystroke through framework state. The framework is always the single source of truth for the field value, which makes validation, conditional rendering, and cross-field logic straightforward. The cost is a synchronous state update on every character — fine for most forms, measurable at scale when field counts exceed fifty or validators run heavy synchronous transforms.
Uncontrolled components store their value in the DOM. The framework does not own the value; you read it on demand via a ref or FormData. This eliminates per-keystroke re-renders, but complicates cross-field dependency resolution because you must imperatively pull values rather than reading from a reactive store.
Tracking which fields have changed requires explicit dirty and pristine state tracking logic in both cases. For controlled components, compare current state against the initial snapshot. For uncontrolled components, compare the DOM’s current value against a snapshot stored at mount time.
Event Delegation & Render Batching
Controlled inputs trigger synchronous state updates, which React 18 batches automatically across most event handlers. Uncontrolled inputs bypass the render queue entirely — reads happen imperatively, outside the framework’s scheduling. Framework event pooling and synthetic event normalization both introduce subtle timing differences: React’s onChange fires on every character, but a native input listener fires before React’s own handler. Test both paths with your target browsers before committing to one model.
Vue’s v-model directive wraps a controlled pattern but allows .lazy to debounce to change events — a useful middle ground when per-keystroke updates are too expensive but full uncontrolled DOM ownership is undesirable.
Validation Pipeline Integration
The form validation lifecycle applies to both paradigms, but the wiring diverges at the point of value extraction:
- Controlled forms: validators receive values directly from state. Real-time schema evaluation and inline error injection work without extra plumbing. Schema libraries like Zod can evaluate on every state change, and the error shape flows directly to UI components via error state mapping patterns.
- Uncontrolled forms: validators must pull values from refs or the Constraint Validation API (
input.checkValidity(),input.setCustomValidity()). Validation results must be written back to a parallel error state — not to the field value itself — and then re-rendered as error messages.
The most important architectural constraint is that validation rules must be decoupled from both paradigms: pure functions that accept a value and return string | null or Promise<string | null> compose identically for controlled and uncontrolled fields, and they remain testable without a DOM.
Core Implementation: Hybrid Adapter
Large forms routinely mix controlled validation logic with uncontrolled performance characteristics. The adapter below standardises value extraction, cancellable async validation, and error accumulation across both input types.
// AbortController stored per-field so each new validation run can cancel the previous one.
// Without this, a slow network validator from keystroke N can overwrite the result of keystroke N+1.
type ValidationRule<T> = (
value: T,
context: Record<string, unknown>,
signal: AbortSignal // Passed through so fetch/async calls can self-cancel
) => Promise<string | null> | string | null;
interface FormAdapterConfig<T extends Record<string, unknown>> {
controlledFields: (keyof T)[];
uncontrolledFields: (keyof T)[];
schema: Partial<Record<keyof T, ValidationRule<T[keyof T]>>>;
}
export class FormValidationAdapter<T extends Record<string, unknown>> {
private config: FormAdapterConfig<T>;
private errors: Partial<Record<keyof T, string>> = {};
// One AbortController per field — cancels the previous async validator on each new invocation.
// WeakMap would be ideal for DOM-node keys, but field names are strings so Map is correct here.
private controllers: Map<keyof T, AbortController> = new Map();
constructor(config: FormAdapterConfig<T>) {
this.config = config;
}
async validateField(
field: keyof T,
value: T[keyof T],
context: T
): Promise<string | null> {
const rule = this.config.schema[field];
if (!rule) return null;
// Abort any in-progress validation for this field before starting a new one.
// This prevents out-of-order error rendering when the user types faster than the validator resolves.
const prev = this.controllers.get(field);
if (prev) prev.abort();
const controller = new AbortController();
this.controllers.set(field, controller);
try {
const error = await Promise.resolve(
rule(value, context, controller.signal)
);
// Discard the result if the field was edited again before this run finished.
if (controller.signal.aborted) return null;
if (error) {
this.errors[field] = error;
} else {
delete this.errors[field];
}
return error;
} catch (err) {
if (controller.signal.aborted) return null;
const msg = err instanceof Error ? err.message : 'Validation error occurred';
this.errors[field] = msg;
return msg;
} finally {
// Remove the controller once the run is complete to avoid a memory leak
// when the field is unmounted before the next validation cycle.
if (this.controllers.get(field) === controller) {
this.controllers.delete(field);
}
}
}
/** Read all uncontrolled field values from the DOM at validation or submit time. */
extractUncontrolledValues(formRef: HTMLFormElement | null): Partial<T> {
if (!formRef) return {};
// FormData is the canonical API for reading uncontrolled inputs — it handles
// checkboxes, multi-selects, and file inputs correctly without manual iteration.
const formData = new FormData(formRef);
const values: Partial<T> = {};
for (const field of this.config.uncontrolledFields) {
const rawValue = formData.get(field as string);
if (rawValue !== null) {
values[field] = rawValue as unknown as T[keyof T];
}
}
return values;
}
getErrors(): Readonly<Partial<Record<keyof T, string>>> {
return { ...this.errors };
}
/** Abort all pending validators and clear error state — call on form reset. */
resetState(): void {
// Cancel every in-flight async validator to prevent stale results
// from re-appearing after the form is reset to its pristine state.
for (const controller of this.controllers.values()) {
controller.abort();
}
this.controllers.clear();
this.errors = {};
}
}
Wiring the Adapter to ARIA Error Attributes
After validateField resolves, write the error string to a <span> with a matching id, then point the input’s aria-describedby at that id and toggle aria-invalid:
function applyFieldError(
inputEl: HTMLInputElement,
errorEl: HTMLElement,
error: string | null
): void {
if (error) {
errorEl.textContent = error;
inputEl.setAttribute('aria-invalid', 'true');
// aria-describedby must reference the error container's id so screen readers
// announce the message when the field receives focus after a failed submission.
inputEl.setAttribute('aria-describedby', errorEl.id);
} else {
errorEl.textContent = '';
inputEl.removeAttribute('aria-invalid');
inputEl.removeAttribute('aria-describedby');
}
}
This pattern applies whether the field is controlled (call after state update) or uncontrolled (call after extractUncontrolledValues). For more on mapping errors to UI components, see mapping validation errors to UI components.
Integration Guidance
The adapter slots into the form state fundamentals architecture as the normalisation layer between the DOM and your validation schema:
- At mount, pass your field config to
FormValidationAdapter— this is the only place where controlled vs. uncontrolled ownership is declared. - On each
inputorblurevent, callvalidateFieldwith the current value. For controlled fields, read from state. For uncontrolled fields, passevent.target.valuedirectly (do not wait for a ref read). - On submit, call
extractUncontrolledValuesto harvest the full DOM snapshot, merge it with controlled state, then runvalidateFieldacross all fields in parallel. - On reset, call
resetStatebefore restoring initial values, ensuring no aborted validators resurface stale errors.
For React specifically, the adapter class instance should live in a useRef (not useState) so it is stable across renders. See building a custom useFormField hook for a complete React integration.
Edge Cases & Failure Modes
Hydration mismatches with SSR
When the server renders an input as controlled (with a value attribute) but the client mounts it as uncontrolled, React’s reconciler will either warn or silently drop the server-rendered value. Resolution: during SSR, read the initial value from the store and write it to the defaultValue prop for uncontrolled inputs, never to value. After hydration the ref picks up the DOM value and stays in sync.
Autofill bypasses uncontrolled refs
Browser autofill can populate inputs without triggering input or change events, leaving refs stale. Resolution: listen for the animationstart event on inputs with the autocomplete attribute set — browsers trigger a CSS animation on autofilled fields that you can intercept to force a ref read.
Shadow DOM field boundaries
Custom elements inside a shadow root do not participate in the outer FormData collection unless the element implements the ElementInternals API with setFormValue. Uncontrolled refs that point across shadow boundaries may return null. Resolution: use internals.setFormValue() in the custom element and validate via its form property rather than a direct ref.
React.StrictMode double-invocation
In development, StrictMode mounts and unmounts components twice. If resetState or the adapter constructor has side effects (timers, subscriptions), they fire twice and may leave the error map in an unexpected state. Resolution: make the constructor idempotent — initialise errors and controllers as empty maps, never from a pre-populated argument.
Cross-field validators reading stale uncontrolled values
If field A’s validator reads field B’s uncontrolled ref synchronously, and field B was just programmatically reset, the ref may still hold the old value. Resolution: always extract uncontrolled values via extractUncontrolledValues at the start of a cross-field validation run, never from a cached ref snapshot taken at mount.
Troubleshooting Reference
| Failure scenario | Diagnostic step | Recovery action |
|---|---|---|
| Error message appears after reset | Check resetState is called before restoring initial values |
Call adapter.resetState() first, then set controlled state / reset form element |
| Stale async error overwrites valid result | Log controller.signal.aborted inside the validator |
Add if (signal.aborted) return null as the first line of async validators |
| Uncontrolled field always reads empty string | Check name attribute matches the key in uncontrolledFields |
Ensure every uncontrolled input has a name attribute; FormData.get(key) returns null without it |
| Hydration warning on SSR uncontrolled input | Inspect server HTML for value= attribute on uncontrolled inputs |
Switch to defaultValue prop; never set value on an uncontrolled input after mount |
aria-describedby not announced by screen reader |
Verify the error <span> id exists in the DOM before the input renders |
Render the error container in the initial HTML even when empty; do not conditionally mount it |
Testing & QA Hooks
Attach data-field and data-field-state attributes to every input so Playwright and Cypress selectors stay decoupled from CSS class names:
// Set on mount and update after every validateField call
inputEl.dataset.field = String(field);
inputEl.dataset.fieldState = error ? 'invalid' : 'valid';
In Playwright:
// Select a specific field regardless of its position in the DOM
const emailInput = page.locator('[data-field="email"]');
await expect(emailInput).toHaveAttribute('aria-invalid', 'true');
await expect(page.locator('[data-field="email"][data-field-state="invalid"]')).toBeVisible();
For accessibility regression coverage, assert that every aria-invalid="true" input has a non-empty aria-describedby target. A Playwright helper that iterates data-field-state="invalid" elements and checks their aria-describedby chain catches the category of error most likely to regress across refactors.
Common Pitfalls
- Mixing
valueanddefaultValueon the same input. React treats an input withvalueas controlled and one withdefaultValueas uncontrolled. Setting both — or switching between them at runtime — produces the “A component is changing an uncontrolled input to be controlled” warning and undefined state. - Reading
ref.current.valueinside an async callback. By the time a delayed validator resolves, the ref may point to a different value. Captureref.current.valuesynchronously at the start of the validation run and pass it as a closed-over variable. - Forgetting
nameattributes on uncontrolled inputs.FormDatasilently omits inputs without aname. The omission produces no error — the value is simply missing from the extracted object, causing silent validation bypasses. - Not aborting validators on unmount. If a component unmounts mid-validation and the adapter is not destroyed, the pending
AbortControllerholds a closure reference to the component’s error state, preventing garbage collection. CallresetState()in the component’s cleanup function. - Triggering cross-field validation synchronously on every keystroke. When field A’s validator reads field B’s value synchronously on every character typed in field A, it produces N² reads for an N-field form. Debounce cross-field runs and batch them to submit or blur events.
Frequently Asked Questions
When should I choose uncontrolled over controlled forms?
Uncontrolled forms are a good fit for high-field-count inputs (fifty or more fields), file uploads, or cases where a third-party library owns the DOM element and does not expose a controlled-value interface. They cut render cycles but require manual validation wiring via refs or FormData. Controlled forms are simpler to reason about and test for anything interactive where real-time cross-field feedback is expected.
How do I handle cross-field validation in uncontrolled components?
Implement a centralised validation coordinator that calls extractUncontrolledValues on blur or submit to get a full value snapshot, then runs cross-field checks against that snapshot. Write the resulting errors to a separate error state (not back to the input value) and re-render error messages from there. Never read refs inside the cross-field validator itself — the ref is a live reference that changes between keystrokes.
Can controlled and uncontrolled inputs coexist in the same form?
Yes, through an adapter that normalises value extraction and validation routing. The adapter tracks which fields are controlled and which are uncontrolled, preventing state collisions and ensuring consistent error propagation across both paradigms. The key constraint is that the ownership of each field must be fixed at mount time and must not change during the component’s lifetime.
How do I cancel an in-flight async validator when the user edits the field again?
Store one AbortController per field in the adapter (a Map<keyof T, AbortController> keyed by field name). Before starting a new validation run, call abort() on the existing controller for that field. Inside the async validator, accept the AbortSignal as a parameter and pass it to any fetch calls. Check signal.aborted before writing results back to error state. This pattern is shown in full in the validateField implementation above.
Related
- Best Practices for Uncontrolled Form State — imperative DOM read patterns, ref snapshots, and reset safety
- Dirty and Pristine State Tracking — comparing current values against initial snapshots for both paradigms
- Form Validation Lifecycle — the full event sequence from mount through submit and teardown
- Error State Mapping Patterns — routing validation errors to the correct UI components