{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreifjpq6lo5gjf2ispzg5pttgfxzirc2gjpinlj7hyjiq4wervydhgq",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpj5u7pmoj72"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreid2deihlohzftkusr3aqtx5m5cmpdxrv4mjuoolyadyy4y4qmkbku"
    },
    "mimeType": "image/webp",
    "size": 234668
  },
  "path": "/n3lix/we-wanted-a-simple-forms-api-so-i-built-my-own-library-2248",
  "publishedAt": "2026-06-30T13:37:00.000Z",
  "site": "https://dev.to",
  "tags": [
    "react",
    "opensource",
    "nextjs",
    "webdev",
    "GitHub",
    "Docs",
    "npm",
    "gform-react",
    "Standard Schema",
    "Standard Schema v1"
  ],
  "textContent": "GitHub | Docs | npm\n\nForms are supposed to be simple. You've got a couple of inputs, some labels, a submit button. HTML sorted this out decades ago.\n\nSo why does building one in React feel like assembling IKEA furniture with half the instructions missing?\n\n##  The problem\n\nAt work we build and maintain a lot of forms. We started on Formik, then tried react-hook-form. Both are popular and well documented, and honestly they're fine libraries. But with both of them I kept feeling like I was spending my time on the tooling instead of on the product.\n\nAll I wanted was a plain `<form>` with a few inputs, a bit of validation, an easy way to pull the values back out, and a POST at the end.\n\n\n\n    <form onSubmit={onSubmit}>\n        <input name=\"fullName\" required />\n        <input name=\"email\" type=\"email\" required />\n        <button>Submit</button>\n    </form>\n\n\nThis is basically free in HTML. It's the validation and getting the values back out cleanly that isn't.\n\n##  The basic idea\n\nThe whole point of gform-react is that it should feel closer to writing HTML than to configuring a library.\n\nYou write an input the way you'd write any other React component. At its simplest, gform renders a native `<input>`, and standard attributes like `className` and `placeholder` go right on `GInput`, which forwards them through:\n\n\n\n    <GInput formKey=\"email\" type=\"email\" required className=\"input\" placeholder=\"Email\" />\n\n\nNeed custom markup or inline errors? Pass an `element`. You spread `props` onto your input like you always would, and `input.error` and `input.errorText` are right there where you're rendering it, so no digging into `formState.errors.email?.message`, no `Controller` and no resolver setup:\n\n\n\n    <GInput formKey=\"email\"\n        type=\"email\"\n        required\n        placeholder=\"Email\"\n        element={(input, props) => (\n            <div>\n                <input {...props} />\n                {input.error && <small>{input.errorText}</small>}\n            </div>\n        )}\n    />\n\n\nStandard input attributes belong on `GInput` (they're forwarded either way); keep custom props on your `element`.\n\n##  Dependent fields are where it actually hurts\n\nAlmost every real form has at least one. A city dropdown that only makes sense once you've picked a country, a street list that reloads when the city changes, that sort of thing.\n\nThe usual way to handle this looks something like:\n\n\n\n    const [cities, setCities] = useState([]);\n    const [loading, setLoading] = useState(false);\n    const country = watch(\"country\");\n\n    useEffect(() => {\n        if (!country) return;\n        setLoading(true);\n        loadCities(country).then((cities) => {\n            setCities(cities);\n            setValue(\"city\", cities[0]);\n            setLoading(false);\n        });\n    }, [country]);\n\n\nIt works, but none of it actually lives in the form. It's wired together outside it, in your component, with a pile of `useState` and duct tape.\n\nSo I came up with another solution:\n\n\n\n    <GInput formKey=\"city\"\n        fetchDeps={[\"country\"]}\n        fetch={async (input, fields) => {\n            const cities = await loadCities(fields.country.value);\n            return { options: cities, value: cities[0] };\n        }}\n        element={renderCity}\n    />\n\n\n`fetchDeps` watches the `country` field. When it changes, `fetch` runs, loads the new cities, and pushes the result back into form state for you. The dependency tracking and the dispatch live inside the form instead of in your component.\n\n##  Adding custom data to a field\n\nThat `fetch` attaches data automatically. You can do the same by hand with `dispatchChanges`: it merges whatever you give it onto the field's state, so you can park extra data there: a list of options, a loading flag, a label, whatever. then read it straight back in `element`.\n\nSay you've loaded a city list and want to keep the selected value and the options together on the field:\n\n\n\n    state.city.dispatchChanges({\n        value: cities[0],\n        options: cities, // custom data, rides along on the field\n    });\n\n\nThen read it where you render the input:\n\n\n\n    <GInput formKey=\"city\"\n        element={(input, props) => (\n            <select {...props}>\n                {input.options?.map((c) => (\n                    <option key={c} value={c}>{c}</option>\n                ))}\n            </select>\n        )}\n    />\n\n\nNo extra `useState`, no second source of truth. Add `{ validate: true }` as a second argument if you passed a new value and if it should re-run validation.\n\n##  What about native submission and Next.js Server Actions?\n\nNative `<form>` submission and Next.js Server Actions work without any extra wiring. Submit through `action` instead of `onSubmit`, and gform still runs your client validation first and blocks an invalid submit before it ever reaches the server:\n\n\n\n    <GForm action={myServerAction}>\n        <GInput formKey=\"email\" type=\"email\" required element={/* render input */} />\n        <button>Submit</button>\n    </GForm>\n\n\n##  Validation\n\nNative HTML constraints (`required`, `minLength`, `pattern`, `type`, and the rest) work out of the box. The only thing you add is the message. Here's a full subscribe form:\n\n\n\n    import {GForm, GInput, GValidator} from \"gform-react\";\n\n    interface ISubscribeForm {\n        name: string;\n        email: string;\n    }\n\n    const baseValidator = new GValidator().withRequiredMessage(input => `${input.name} is required`);\n\n    // '*' is the default for every field; a message can be a string or a function\n    const validators = {\n        \"*\": baseValidator,\n        name: new GValidator(baseValidator).withMinLengthMessage(\"At least 2 characters\"),\n        email: new GValidator(baseValidator).withPatternMismatchMessage(\"Enter a valid email\")\n    };\n\n    export const SubscribeForm = () => {\n        return (\n            <GForm<ISubscribeForm>\n                validators={validators}\n                onSubmit={(state, e) => {\n                    e.preventDefault();\n                    console.log(state.toRawData()); // { name, email }\n                }}>\n                {(state) => (\n                    <>\n                        <GInput formKey=\"name\"\n                            required\n                            minLength={2}\n                            placeholder=\"Name\"\n                            element={(input, props) => (\n                                <div>\n                                    <input {...props} />\n                                    {input.error && <small>{input.errorText}</small>}\n                                </div>\n                            )}\n                        />\n                        <GInput formKey=\"email\"\n                            type=\"email\"\n                            required\n                            pattern=\"[^@\\s]+@[^@\\s]+\\.[^@\\s]+\"\n                            placeholder=\"Email\"\n                            element={(input, props) => (\n                                <div>\n                                    <input {...props} />\n                                    {input.error && <small>{input.errorText}</small>}\n                                </div>\n                            )}\n                        />\n                        <button disabled={state.isInvalid}>Subscribe</button>\n                    </>\n                )}\n            </GForm>\n        );\n    }\n\n\nThe `pattern` on `email` is enough for gform to show your `patternMismatch` message instead of the browser's generic `typeMismatch` (pattern wins when both fail). A message can be a plain string or a function of the input, and `\"*\"` applies a validator to every field unless you override it, the way `email` does here.\n\nNeed a rule native HTML can't express? Add a custom check. You return `true` to mark the field invalid (you're answering \"is this broken?\"), and set the message on `input.errorText`:\n\n\n\n    const validators = {\n        fullName: new GValidator().withCustomValidation((input) => {\n            input.errorText = \"please pick another name\";\n            return input.value === \"admin\"; // true means invalid\n        })\n    };\n\n\nPrefer a schema? Hand `withSchema` a Zod, Valibot, or ArkType, Joi, or any other library that implements Standard Schema and it validates the whole form, cross-field rules included (confirm-password and the like), because it parses the entire object at once rather than field by field:\n\n\n\n    const validators = {\n        \"*\": new GValidator().withSchema(signUpSchema),\n    };\n\n\nYup is async-first, so use `withSchemaAsync` for that one. Either way it's the same schema you can reuse on the backend, so you're not keeping two copies of your validation rules in sync.\n\n##  Getting the data back out\n\nOnce the form is valid you don't have to reassemble the payload field by field. One call gives you the whole thing, typed, in whatever format you need:\n\n\n\n    onSubmit={(state, e) => {\n        e.preventDefault();\n\n        state.toRawData();         // → { fullName, email }     plain object, fully typed\n        state.toFormData();        // → FormData                ready for fetch() / file uploads\n        state.toURLSearchParams(); // → URLSearchParams         ready for a query string\n    }}\n\n\nNo `watch()`, no calling `getValues` for each field, no building the object by hand. `toRawData()` is typed from your form interface, so you get back `{ fullName: string; email: string }` rather than `any`. And if you need to massage a value on the way out, each of them takes a per-field `transform`, `exclude`, and `include`:\n\n\n\n    state.toRawData({\n        transform: { tags: (v) => v.join(\",\") },\n        exclude: ['lastName']\n    });\n\n\n##  Other things it does\n\n  * **Tiny & no dependencies** - 4.8 KB gzipped, tree‑shakable\n  * **Minimal re-renders** - updates only the fields that actually change\n  * **Native HTML constraint validation** - full support for `min`, `max`, `pattern`, `minLength`, `maxLength`, `required`, and more\n  * **Schema validation via Standard Schema v1** - any library implementing the spec works out of the box (Zod, Valibot, ArkType, Yup, …); drive the whole form from one schema via `GValidator.withSchema` / `withSchemaAsync`, including object-level cross-field rules - with zero runtime dependencies\n  * **Custom & async validation** - add any rule via `withCustomValidation`, including asynchronous server-side checks with `withCustomValidationAsync`\n  * **Cross-field validation** - re-validate a field when another changes (e.g. confirm-password) via `validatorDeps`\n  * **Deeply Nested Forms** - structure forms however you like, split a big form into focused and reusable components\n  * **Dynamic fields** - add or remove fields at runtime without losing state\n  * **Native`<form>` actions** - fully supports browser‑level form submission, including action, method, and HTTP navigation, with no JavaScript required\n  * **Next.js Server Actions support** - works seamlessly with Server Actions through standard `<form>` submissions, with no special adapters or client‑side wiring\n  * **Custom data on any input** - attach arbitrary data to a field via `dispatchChanges` (option lists, loading flags, fetched metadata); it's kept in form state for your UI, separate from the submitted value\n  * **Accessibility‑friendly** - automatically manages `aria-required` and `aria-invalid`\n  * **File inputs** - `type=\"file\"` stores the real `File` object (or `File[]` with `multiple`), not the `C:\\fakepath\\...` string\n  * **React Native support** - the same API on web and mobile (via `gform-react/native`); no adapters, no separate mental model\n\n\n\n##  The honest part\n\ngform-react was a private library. There aren't years of tutorials, a pile of Stack Overflow answers, or a big ecosystem around it. I built it because we needed it, we run it in production, and I fix the rough edges as we hit them.\n\nIf you're happy with your current setup, you probably don't need it.\n\nBut if you've ever stared at a form component and wondered why something this basic turned into this much code, it might be worth a look.",
  "title": "We wanted a simple forms API, so I built my own library"
}