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).withTypeMismatchMessage("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
    {...extra}
    element={(input, props) => (
      <div>
        <input {...props} placeholder={formKey} />
        {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")}
          {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, type="email") 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 Getting Started or the Examples.