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:
| Library | Input model | How re-renders are kept small |
|---|---|---|
| gform-react | Controlled (value + onChange) | An external store (useSyncExternalStore) with a per-field selector + memo - a keystroke only re-renders the field whose slice changed. |
| react-hook-form | Uncontrolled (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. |
| Formik | Controlled (value + onChange) | None by default - all connected fields share one state object and re-render together on every keystroke. |
| react-final-form | Controlled (value + onChange) | A framework-agnostic store (final-form) with per-<Field> subscriptions, so typing only re-renders the subscribed field. |
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:
| Library | Version | Min + gzip | Runtime deps |
|---|---|---|---|
| gform-react | 3.0.0 | ~4.5 kB | None (zero runtime deps) |
| react-hook-form | 7.x | ~9 kB | None (zero runtime deps) |
| react-final-form | 7.0.1 | ~10.6 kB | requires final-form core |
| formik | 2.4.9 | 12.8 kB | several (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.
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.
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/useWatchsubscription (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 (confirmmatchingpassword) needwatch("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 separatefinal-formcore 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
elementrender prop - but that render prop is exactly what gives you full control over markup and any UI library, and theGValidatorchain maps native HTML constraints (required,minLength,type="email") to messages without a schema dependency. Cross-field rules read naturally via(input, fields).
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
| Capability | gform-react | react-hook-form | Formik | react-final-form |
|---|---|---|---|---|
| Input model | Controlled, store-isolated | Uncontrolled (refs) | Controlled, global | Controlled, subscription-isolated |
| Min + gzip bundle | ~4.5 kB | ~9 kB | ~13 kB | ~11 kB (with final-form) |
| Re-render on keystroke | Changed field only | None (uncontrolled) | All connected fields | Changed 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 inputs | ✅ element render prop | ⚠️ needs <Controller> | ✅ | ✅ <Field> render prop |
| React Native | ✅ gform-react/native | ✅ supported | ⚠️ unofficial | ⚠️ bring-your-own input |
| Programmatic value access | ✅ toRawData() (in state) | ⚠️ getValues()/watch | ✅ | ✅ (form.getState()) |
| Ecosystem & maturity | Newer, smaller | Most popular today | Very mature | Mature, less active |
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.