Skip to main content
Technology & EngineeringForms Validation297 lines

React Final Form

React Final Form — subscription-based form state management with fine-grained re-renders, field-level validation, decorator support, and zero dependencies

Quick Summary13 lines
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 lines
Paste into your CLAUDE.md or agent config

React 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 subscription props 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 onSubmit rather 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.error and meta.submitError are different. meta.error comes from validation functions (field-level or form-level), while meta.submitError comes from the object returned by onSubmit. 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 the subscription prop, or use the useField hook 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

Get CLI access →