gform-react

Comparison & Benchmarks

How gform-react compares to react-hook-form, Formik, and react-final-form - architecture, bundle size, render timings at scale, and ergonomics.

react-hook-form, Formik, and react-final-form are all excellent, battle-tested libraries with large communities - react-hook-form especially is the most popular choice today. This page is an honest, source-accurate comparison, including where each of the others is a better fit.

Architecture: how each one tracks input

This is the most misunderstood part, so let's be precise:

LibraryInput modelHow re-renders are kept small
gform-reactControlled (value + onChange)An external store (useSyncExternalStore) with a per-field selector + memo - a keystroke only re-renders the field whose slice changed.
react-hook-formUncontrolled (register + refs)The DOM input owns its own value, so a keystroke triggers no React re-render at all - state is read on submit or via watch/useWatch subscriptions.
FormikControlled (value + onChange)None by default - all connected fields share one state object and re-render together on every keystroke.
react-final-formControlled (value + onChange)A framework-agnostic store (final-form) with per-<Field> subscriptions, so typing only re-renders the subscribed field.
The key insight

The honest split is controlled vs uncontrolled. react-hook-form is uncontrolled: each input keeps its own value in the DOM, so typing causes zero React re-renders - the fewest by design. The trade-off is that reading or reacting to a value mid-render needs a watch/useWatch subscription (which re-renders), and controlled UI components need a <Controller> wrapper. The other three are controlled - every value lives in React state, which makes reading values, derived UI, programmatic updates, and toRawData() trivial. Among the controlled libraries, Formik re-renders every connected field per keystroke; react-final-form and gform-react each isolate to the changed field via an external store. gform-react keeps controlled-input ergonomics with the smallest bundle and a typed, native-validation-first API.

Bundle size

Smaller bundles ship faster. Measured with Bundlephobia (minified + gzip), June 2026:

LibraryVersionMin + gzipRuntime deps
gform-react3.0.0~4.5 kBNone (zero runtime deps)
react-hook-form7.x~9 kBNone (zero runtime deps)
react-final-form7.0.1~10.6 kBrequires final-form core
formik2.4.912.8 kBseveral (lodash-es, etc.)

gform-react is the smallest of the four and tree-shakable. react-hook-form, the most popular option, is also zero-dependency but roughly 2× larger. react-final-form's figure includes its required final-form core (~6.7 kB) on top of react-final-form itself (~3.9 kB). (Numbers drift - click any package to check the current size.)

Performance benchmark - 100 validated fields

Render cost only makes sense to compare once you account for the controlled/uncontrolled split:

  • react-hook-form - uncontrolled: the input value lives in the DOM, so typing causes effectively no React re-render. It is the fastest here by design - that's the headline benefit of going uncontrolled.
  • Formik - controlled, re-renders every connected field on each keystroke.
  • react-final-form - controlled, but subscription-optimized so each <Field> re-renders on its own changes (the Form body subscribes to nothing).
  • gform-react - controlled, with an external store + per-field selector, so only the edited field re-renders.

Each of 100 fields validates on change (minimum 8 characters, with a live "x/8" message) and shows its error inline. The sandbox auto-types 20 keystrokes into the first field while React's <Profiler> measures the actual render time per keystroke. Press Re-run to measure again.

Reading the numbers honestly

These are measured live on your machine, inside a development build of React (Sandpack), so the absolute milliseconds are inflated and vary run-to-run. React's StrictMode also double-invokes render functions in development. Expect react-hook-form to post the lowest number because, being uncontrolled, it skips React on each keystroke entirely - the honest cost of that is paid elsewhere (reading values needs watch, controlled UIs need <Controller>). Among the controlled libraries the meaningful ratio is: Formik re-renders all 100 validated fields per keystroke, while react-final-form and gform-react each re-render only the field you're editing.

Loading playground…

Reading the results honestly:

  • react-hook-form posts the lowest time because it's uncontrolled - keystrokes never reach React. That's a genuine advantage if you don't need controlled values. You pay for it in ergonomics: reading a value to drive other UI means a watch/useWatch subscription (which re-renders), and any controlled component needs a <Controller> wrapper.
  • Formik re-renders all 100 validated fields every keystroke - by far the highest per-keystroke time of the controlled libraries.
  • react-final-form isolates with field subscriptions, so it re-renders only the edited field - much faster than Formik, and a fair like-for-like peer for gform-react.
  • gform-react likewise re-renders only the edited field via its store selector, staying right alongside react-final-form while keeping the simplest typed API.

The takeaway: if raw per-keystroke render count is your only metric, an uncontrolled library like react-hook-form wins - that's what uncontrolled buys you. gform-react's bet is different: keep controlled values (trivial reads, derived UI, toRawData(), programmatic updates) while paying almost none of the usual controlled re-render cost - matching the best controlled peer (react-final-form) and crushing the most common one (Formik), in a smaller bundle.

Ergonomics - the same validated form in each

Performance isn't everything; how the code reads matters too. Here's the same form - email + password (min 8) + "confirm must match" - in each library.

gform-react

import { GForm, GInput, GValidator, type GValidators } from "gform-react";
 
interface Form { email: string; password: string; confirm: string }
 
const base = new GValidator().withRequiredMessage("Required");
const validators: GValidators<Form> = {
  "*": base,
  email: new GValidator(base).withPatternMismatchMessage("Invalid email"),
  password: new GValidator(base).withMinLengthMessage("Min 8 characters"),
  confirm: new GValidator(base).withCustomValidation((input, fields) => {
    if (input.value !== fields.password.value) {
      input.errorText = "Passwords must match";
      return true; // true = error
    }
    return false;
  }),
};
 
const Row = (formKey: keyof Form, type = "text", extra = {}) => (
  <GInput
    formKey={formKey}
    type={type}
    required
    placeholder={formKey}
    {...extra}
    element={(input, props) => (
      <div>
        <input {...props} />
        {input.error && <small>{input.errorText}</small>}
      </div>
    )}
  />
);
 
export default function App() {
  return (
    <GForm<Form> validators={validators} onSubmit={(s, e) => { e.preventDefault(); /* s.toRawData() */ }}>
      {(state) => (
        <>
          {Row("email", "email", { pattern: "[^\s@]+@[^\s@]+\.[^\s@]+" })}
          {Row("password", "password", { minLength: 8 })}
          {Row("confirm", "password")}
          <button disabled={state.isInvalid}>Submit</button>
        </>
      )}
    </GForm>
  );
}

react-hook-form

import { useForm } from "react-hook-form";
 
interface Form { email: string; password: string; confirm: string }
 
export default function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isValid },
  } = useForm<Form>({ mode: "onChange" });
  const password = watch("password");
 
  return (
    <form onSubmit={handleSubmit((values) => { /* values */ })}>
      <div>
        <input
          placeholder="email"
          {...register("email", {
            required: "Required",
            pattern: { value: /^[^@]+@[^@]+$/, message: "Invalid email" },
          })}
        />
        {errors.email && <small>{errors.email.message}</small>}
      </div>
 
      <div>
        <input
          type="password"
          placeholder="password"
          {...register("password", {
            required: "Required",
            minLength: { value: 8, message: "Min 8 characters" },
          })}
        />
        {errors.password && <small>{errors.password.message}</small>}
      </div>
 
      <div>
        <input
          type="password"
          placeholder="confirm"
          {...register("confirm", {
            required: "Required",
            validate: (v) => v === password || "Passwords must match",
          })}
        />
        {errors.confirm && <small>{errors.confirm.message}</small>}
      </div>
 
      <button disabled={!isValid}>Submit</button>
    </form>
  );
}

react-final-form

import { Form, Field } from "react-final-form";
 
const required = (v?: string) => (v ? undefined : "Required");
const email = (v?: string) => (/^[^@]+@[^@]+$/.test(v ?? "") ? undefined : "Invalid email");
const min8 = (v?: string) => ((v ?? "").length >= 8 ? undefined : "Min 8 characters");
const compose =
  (...fns: Array<(v?: string) => string | undefined>) =>
  (v?: string) => fns.reduce<string | undefined>((err, fn) => err || fn(v), undefined);
 
export default function App() {
  return (
    <Form
      onSubmit={(values) => { /* values */ }}
      validate={(values) => {
        const errors: Record<string, string> = {};
        if (values.confirm !== values.password) errors.confirm = "Passwords must match";
        return errors;
      }}
      render={({ handleSubmit, invalid }) => (
        <form onSubmit={handleSubmit}>
          <Field name="email" validate={compose(required, email)}>
            {({ input, meta }) => (
              <div>
                <input {...input} placeholder="email" />
                {meta.touched && meta.error && <small>{meta.error}</small>}
              </div>
            )}
          </Field>
 
          <Field name="password" type="password" validate={compose(required, min8)}>
            {({ input, meta }) => (
              <div>
                <input {...input} placeholder="password" />
                {meta.touched && meta.error && <small>{meta.error}</small>}
              </div>
            )}
          </Field>
 
          <Field name="confirm" type="password" validate={required}>
            {({ input, meta }) => (
              <div>
                <input {...input} placeholder="confirm" />
                {meta.touched && meta.error && <small>{meta.error}</small>}
              </div>
            )}
          </Field>
 
          <button disabled={invalid}>Submit</button>
        </form>
      )}
    />
  );
}

Formik (with Yup)

import { Formik, Form, Field, ErrorMessage } from "formik";
import * as Yup from "yup";
 
const schema = Yup.object({
  email: Yup.string().email("Invalid email").required("Required"),
  password: Yup.string().min(8, "Min 8 characters").required("Required"),
  confirm: Yup.string()
    .oneOf([Yup.ref("password")], "Passwords must match")
    .required("Required"),
});
 
export default function App() {
  return (
    <Formik
      initialValues={{ email: "", password: "", confirm: "" }}
      validationSchema={schema}
      onSubmit={(values) => { /* values */ }}
    >
      {({ isValid, dirty }) => (
        <Form>
          <Field name="email" placeholder="email" />
          <ErrorMessage name="email" component="small" />
 
          <Field name="password" type="password" placeholder="password" />
          <ErrorMessage name="password" component="small" />
 
          <Field name="confirm" type="password" placeholder="confirm" />
          <ErrorMessage name="confirm" component="small" />
 
          <button disabled={!(isValid && dirty)}>Submit</button>
        </Form>
      )}
    </Formik>
  );
}

Honest read on ergonomics

  • react-hook-form is the most concise here - register(...) spreads validation rules right onto the input and there's no render prop. That terseness comes from being uncontrolled: the values are in the DOM, so cross-field rules (confirm matching password) need watch("password") to pull the value back into React, and integrating a controlled UI component (a <Select> from a design system) requires wrapping it in <Controller>. gform-react and react-final-form expose the value to you directly because they're controlled.
  • react-final-form keeps validation as small composable functions and isolates re-renders via field subscriptions; cross-field rules (like "confirm must match") go in a record-level validate. The costs: it needs the separate final-form core package, and the render-prop <Field> is a bit verbose.
  • Formik + Yup cleanly separates the schema from the markup; great when you already think in Yup schemas. It needs an extra dependency and is the most re-render-heavy at runtime.
  • gform-react is similarly explicit per field via the element render prop - but that render prop is exactly what gives you full control over markup and any UI library, and the GValidator chain maps native HTML constraints (required, minLength, pattern) to messages without a schema dependency. Cross-field rules read naturally via (input, fields).
Reduce the boilerplate

The per-field render-prop repetition (in both gform-react and react-final-form) is easily factored into a small Field wrapper, as in the gform-react sample above.

Feature comparison

Capabilitygform-reactreact-hook-formFormikreact-final-form
Input modelControlled, store-isolatedUncontrolled (refs)Controlled, globalControlled, subscription-isolated
Min + gzip bundle~4.5 kB~9 kB~13 kB~11 kB (with final-form)
Re-render on keystrokeChanged field onlyNone (uncontrolled)All connected fieldsChanged field only
TypeScript-first✅ (typed)
Native HTML constraint validation + messages✅ first-class⚠️ rules on register❌ (JS / Yup)❌ (JS validators)
Custom & async validation✅ (field + record, async)
Yup / Zod / any schema✅ (in a validator)✅ (resolvers)✅ (Yup native)✅ (record validate)
Native <form> action / Server Actions✅ no adapter⚠️ via Controller/manual
File inputs store the real File✅ automatic✅ (register)⚠️ manual⚠️ manual
Controlled UI library inputselement render prop⚠️ needs <Controller><Field> render prop
React Nativegform-react/native✅ supported⚠️ unofficial⚠️ bring-your-own input
Programmatic value accesstoRawData() (in state)⚠️ getValues()/watch✅ (form.getState())
Ecosystem & maturityNewer, smallerMost popular todayVery matureMature, less active
When another library may fit better

If you want the largest ecosystem and the most tutorials/community answers, react-hook-form is the default choice today, and Formik is also very established. If you specifically want uncontrolled inputs for the absolute fewest re-renders and don't need controlled values, pick react-hook-form. gform-react trades ecosystem breadth for a smaller bundle, controlled-input ergonomics without the re-render cost, and tighter native-form / Server-Action integration.

Summary

  • Choose gform-react for the smallest bundle, controlled values without Formik's re-render cost, native constraint validation with messages, native <form>/Server-Action submission, real file handling, and a shared web + React Native model.
  • Choose react-hook-form if you want an uncontrolled model with the fewest re-renders and the largest ecosystem - and you're comfortable using watch/<Controller> when you need a value or a controlled component.
  • Choose react-final-form for a controlled, subscription-based model with composable field validators and a framework-agnostic core - if you don't need native-form submission or the smallest bundle.
  • Choose Formik if you're invested in it or prefer a Yup-centric, schema-first controlled model.

Ready to try it? Head to Installation or the Examples.