React Final Form
React Final Form — subscription-based form state management with fine-grained re-renders, field-level validation, decorator support, and zero dependencies
You are an expert in using React Final Form for form handling and validation. ## Quick Example ```bash npm install final-form react-final-form ``` ```bash npm install final-form-arrays react-final-form-arrays ```
skilldb get forms-validation-skills/React Final FormFull skill: 297 linesReact Final Form — Forms & Validation
You are an expert in using React Final Form for form handling and validation.
Core Philosophy
Overview
React Final Form is a thin React wrapper around Final Form, a framework-agnostic form state management library. Its key differentiator is subscription-based re-rendering: components only re-render when the specific slice of form state they subscribe to changes. This makes it extremely performant for large forms. It has zero dependencies (beyond React and the tiny Final Form core), supports field-level and form-level validation, async validation, arrays of fields, and a decorator/plugin architecture. It uses the render prop pattern and supports hooks via companion packages.
Setup & Configuration
npm install final-form react-final-form
Basic form:
import { Form, Field } from "react-final-form";
interface FormValues {
firstName: string;
lastName: string;
email: string;
}
function MyForm() {
const onSubmit = async (values: FormValues) => {
const response = await fetch("/api/register", {
method: "POST",
body: JSON.stringify(values),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
// Return submission errors keyed by field name
return { email: "This email is already registered" };
}
};
return (
<Form<FormValues>
onSubmit={onSubmit}
render={({ handleSubmit, submitting, pristine }) => (
<form onSubmit={handleSubmit}>
<Field name="firstName" component="input" placeholder="First Name" />
<Field name="lastName" component="input" placeholder="Last Name" />
<Field name="email" component="input" type="email" placeholder="Email" />
<button type="submit" disabled={submitting || pristine}>
Submit
</button>
</form>
)}
/>
);
}
Core Patterns
Field-Level Validation
const required = (value: any) => (value ? undefined : "Required");
const isEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? undefined : "Invalid email";
const composeValidators =
(...validators: Array<(value: any) => string | undefined>) =>
(value: any) =>
validators.reduce(
(error: string | undefined, validator) => error || validator(value),
undefined
);
function RegistrationForm() {
return (
<Form
onSubmit={onSubmit}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Field name="email" validate={composeValidators(required, isEmail)}>
{({ input, meta }) => (
<div>
<input {...input} type="email" placeholder="Email" />
{meta.touched && meta.error && (
<span className="error">{meta.error}</span>
)}
</div>
)}
</Field>
</form>
)}
/>
);
}
Form-Level Validation
const validate = (values: FormValues) => {
const errors: Partial<Record<keyof FormValues, string>> = {};
if (!values.firstName) errors.firstName = "Required";
if (!values.email) errors.email = "Required";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email))
errors.email = "Invalid email";
if (values.password !== values.confirmPassword)
errors.confirmPassword = "Passwords must match";
return errors;
};
<Form onSubmit={onSubmit} validate={validate} render={...} />;
Subscription-Based Re-rendering
The core performance feature. By default, <Form> subscribes to all state — opt into only what you need:
<Form
onSubmit={onSubmit}
// Only re-render when submitting or hasValidationErrors changes
subscription={{ submitting: true, hasValidationErrors: true }}
render={({ handleSubmit, submitting, hasValidationErrors }) => (
<form onSubmit={handleSubmit}>
{/* Fields manage their own subscriptions independently */}
<Field
name="email"
// Only re-render this field when value or error changes
subscription={{ value: true, error: true, touched: true }}
>
{({ input, meta }) => (
<div>
<input {...input} />
{meta.touched && meta.error && <span>{meta.error}</span>}
</div>
)}
</Field>
<button type="submit" disabled={submitting || hasValidationErrors}>
Submit
</button>
</form>
)}
/>
Async Field Validation
const checkUsername = async (value: string) => {
if (!value) return "Required";
const res = await fetch(`/api/check-username?u=${value}`);
const { available } = await res.json();
return available ? undefined : "Username is taken";
};
<Field name="username" validate={checkUsername}>
{({ input, meta }) => (
<div>
<input {...input} />
{meta.validating && <span>Checking...</span>}
{meta.touched && meta.error && <span>{meta.error}</span>}
</div>
)}
</Field>;
Submission Errors
onSubmit can return an object of field-level errors — these appear as meta.submitError:
const onSubmit = async (values: FormValues) => {
const res = await fetch("/api/register", {
method: "POST",
body: JSON.stringify(values),
});
if (!res.ok) {
const data = await res.json();
// Return errors keyed by field name, or FORM_ERROR for form-level
return { email: data.message, [FORM_ERROR]: "Registration failed" };
}
};
// In the render:
import { FORM_ERROR } from "final-form";
<Form
onSubmit={onSubmit}
render={({ handleSubmit, submitError }) => (
<form onSubmit={handleSubmit}>
{submitError && <div className="form-error">{submitError}</div>}
<Field name="email">
{({ input, meta }) => (
<div>
<input {...input} />
{(meta.error || meta.submitError) && meta.touched && (
<span>{meta.error || meta.submitError}</span>
)}
</div>
)}
</Field>
</form>
)}
/>;
Field Arrays
npm install final-form-arrays react-final-form-arrays
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
<Form
onSubmit={onSubmit}
mutators={{ ...arrayMutators }}
render={({ handleSubmit, form: { mutators: { push, pop } } }) => (
<form onSubmit={handleSubmit}>
<FieldArray name="items">
{({ fields }) =>
fields.map((name, index) => (
<div key={name}>
<Field name={`${name}.product`} component="input" placeholder="Product" />
<Field name={`${name}.quantity`} component="input" type="number" />
<button type="button" onClick={() => fields.remove(index)}>Remove</button>
</div>
))
}
</FieldArray>
<button type="button" onClick={() => push("items", { product: "", quantity: 1 })}>
Add Item
</button>
</form>
)}
/>;
Conditional Fields
import { FormSpy } from "react-final-form";
<Form
onSubmit={onSubmit}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Field name="needsShipping" component="input" type="checkbox" />
{/* Only subscribes to the specific value it needs */}
<FormSpy subscription={{ values: true }}>
{({ values }) =>
values.needsShipping && (
<>
<Field name="address" component="input" placeholder="Shipping address" />
<Field name="zip" component="input" placeholder="Zip code" />
</>
)
}
</FormSpy>
</form>
)}
/>;
Best Practices
- Use
subscriptionprops on both<Form>and<Field>to minimize re-renders. In forms with 50+ fields, subscribing only to the state you render can reduce re-renders from thousands to dozens per keystroke. - Return server-side errors from
onSubmitrather than using separate state management. React Final Form natively supports submission errors via the return value, which automatically clears them when the user modifies the field. - Extract reusable field components that encapsulate the render prop boilerplate — wrap
<Field>with your own<TextField>,<SelectField>, etc., that handle labels, error display, and styling consistently.
Common Pitfalls
- Forgetting that
meta.errorandmeta.submitErrorare different.meta.errorcomes from validation functions (field-level or form-level), whilemeta.submitErrorcomes from the object returned byonSubmit. Always check both when displaying errors, or users will miss server-side feedback. - Using
<FormSpy subscription={{ values: true }}>broadly causes the spy and its children to re-render on every keystroke across the entire form. Narrow the subscription to specific fields using<Field>with thesubscriptionprop, or use theuseFieldhook for precise control.
Anti-Patterns
Over-engineering for hypothetical requirements. Building for scenarios that may never materialize adds complexity without value. Solve the problem in front of you first.
Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide wastes time and introduces risk.
Premature abstraction. Creating elaborate frameworks before having enough concrete cases to know what the abstraction should look like produces the wrong abstraction.
Neglecting error handling at system boundaries. Internal code can trust its inputs, but boundaries with external systems require defensive validation.
Skipping documentation. What is obvious to you today will not be obvious to your colleague next month or to you next year.
Install this skill directly: skilldb add forms-validation-skills
Related Skills
Conform
Conform — progressive enhancement forms, Server Actions, Zod integration, nested objects, arrays, intent buttons, and accessibility patterns
Formik
Formik — useFormik, Field, Form, validation with Yup, FieldArray, custom inputs, submission handling, and error display patterns
React Hook Form
React Hook Form — useForm, register, Controller, validation, nested fields, arrays (useFieldArray), DevTools, and performance optimization patterns
Valibot
Valibot — modular schema validation, tiny bundle size, parse/safeParse, pipe transformations, type inference, and comparison with Zod
Vest
Vest — unit-test-inspired form validation framework with suite/test syntax, async rules, group support, warning-level validations, and framework-agnostic design
Yup
Yup — schema-based object validation with casting, transforms, conditional rules, localized error messages, and deep Formik integration