DEV Community

Cover image for We wanted a simple forms API, so I built my own library
n3lix
n3lix

Posted on

We wanted a simple forms API, so I built my own library

GitHub | Docs | npm


Forms are supposed to be simple. You've got a couple of inputs, some labels, a submit button. HTML sorted this out decades ago.

So why does building one in React feel like assembling IKEA furniture with half the instructions missing?

The problem

At 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.

All 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.

<form onSubmit={onSubmit}>
    <input name="fullName" required />
    <input name="email" type="email" required />
    <button>Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

This is basically free in HTML. It's the validation and getting the values back out cleanly that isn't.

The basic idea

The whole point of gform-react is that it should feel closer to writing HTML than to configuring a library.

You 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:

<GInput formKey="email" type="email" required className="input" placeholder="Email" />
Enter fullscreen mode Exit fullscreen mode

Need 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:

<GInput formKey="email"
    type="email"
    required
    placeholder="Email"
    element={(input, props) => (
        <div>
            <input {...props} />
            {input.error && <small>{input.errorText}</small>}
        </div>
    )}
/>
Enter fullscreen mode Exit fullscreen mode

Standard input attributes belong on GInput (they're forwarded either way); keep custom props on your element.

Dependent fields are where it actually hurts

Almost 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.

The usual way to handle this looks something like:

const [cities, setCities] = useState([]);
const [loading, setLoading] = useState(false);
const country = watch("country");

useEffect(() => {
    if (!country) return;
    setLoading(true);
    loadCities(country).then((cities) => {
        setCities(cities);
        setValue("city", cities[0]);
        setLoading(false);
    });
}, [country]);
Enter fullscreen mode Exit fullscreen mode

It 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.

So I came up with another solution:

<GInput formKey="city"
    fetchDeps={["country"]}
    fetch={async (input, fields) => {
        const cities = await loadCities(fields.country.value);
        return { options: cities, value: cities[0] };
    }}
    element={renderCity}
/>
Enter fullscreen mode Exit fullscreen mode

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.

Adding custom data to a field

That 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.

Say you've loaded a city list and want to keep the selected value and the options together on the field:

state.city.dispatchChanges({
    value: cities[0],
    options: cities, // custom data, rides along on the field
});
Enter fullscreen mode Exit fullscreen mode

Then read it where you render the input:

<GInput formKey="city"
    element={(input, props) => (
        <select {...props}>
            {input.options?.map((c) => (
                <option key={c} value={c}>{c}</option>
            ))}
        </select>
    )}
/>
Enter fullscreen mode Exit fullscreen mode

No 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.

What about native submission and Next.js Server Actions?

Native <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:

<GForm action={myServerAction}>
    <GInput formKey="email" type="email" required element={/* render input */} />
    <button>Submit</button>
</GForm>
Enter fullscreen mode Exit fullscreen mode

Validation

Native 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:

import {GForm, GInput, GValidator} from "gform-react";

interface ISubscribeForm {
    name: string;
    email: string;
}

const baseValidator = new GValidator().withRequiredMessage(input => `${input.name} is required`);

// '*' is the default for every field; a message can be a string or a function
const validators = {
    "*": baseValidator,
    name: new GValidator(baseValidator).withMinLengthMessage("At least 2 characters"),
    email: new GValidator(baseValidator).withPatternMismatchMessage("Enter a valid email")
};

export const SubscribeForm = () => {
    return (
        <GForm<ISubscribeForm>
            validators={validators}
            onSubmit={(state, e) => {
                e.preventDefault();
                console.log(state.toRawData()); // { name, email }
            }}>
            {(state) => (
                <>
                    <GInput formKey="name"
                        required
                        minLength={2}
                        placeholder="Name"
                        element={(input, props) => (
                            <div>
                                <input {...props} />
                                {input.error && <small>{input.errorText}</small>}
                            </div>
                        )}
                    />
                    <GInput formKey="email"
                        type="email"
                        required
                        pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
                        placeholder="Email"
                        element={(input, props) => (
                            <div>
                                <input {...props} />
                                {input.error && <small>{input.errorText}</small>}
                            </div>
                        )}
                    />
                    <button disabled={state.isInvalid}>Subscribe</button>
                </>
            )}
        </GForm>
    );
}
Enter fullscreen mode Exit fullscreen mode

The 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.

Need 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:

const validators = {
    fullName: new GValidator().withCustomValidation((input) => {
        input.errorText = "please pick another name";
        return input.value === "admin"; // true means invalid
    })
};
Enter fullscreen mode Exit fullscreen mode

Prefer 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:

const validators = {
    "*": new GValidator().withSchema(signUpSchema),
};
Enter fullscreen mode Exit fullscreen mode

Yup 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.

Getting the data back out

Once 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:

onSubmit={(state, e) => {
    e.preventDefault();

    state.toRawData();         // → { fullName, email }     plain object, fully typed
    state.toFormData();        // → FormData                ready for fetch() / file uploads
    state.toURLSearchParams(); // → URLSearchParams         ready for a query string
}}
Enter fullscreen mode Exit fullscreen mode

No 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:

state.toRawData({ 
    transform: { tags: (v) => v.join(",") },
    exclude: ['lastName']
});
Enter fullscreen mode Exit fullscreen mode

Other things it does

  • Tiny & no dependencies - 4.8 KB gzipped, tree‑shakable
  • Minimal re-renders - updates only the fields that actually change
  • Native HTML constraint validation - full support for min, max, pattern, minLength, maxLength, required, and more
  • 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
  • Custom & async validation - add any rule via withCustomValidation, including asynchronous server-side checks with withCustomValidationAsync
  • Cross-field validation - re-validate a field when another changes (e.g. confirm-password) via validatorDeps
  • Deeply Nested Forms - structure forms however you like, split a big form into focused and reusable components
  • Dynamic fields - add or remove fields at runtime without losing state
  • Native <form> actions - fully supports browser‑level form submission, including action, method, and HTTP navigation, with no JavaScript required
  • Next.js Server Actions support - works seamlessly with Server Actions through standard <form> submissions, with no special adapters or client‑side wiring
  • 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
  • Accessibility‑friendly - automatically manages aria-required and aria-invalid
  • File inputs - type="file" stores the real File object (or File[] with multiple), not the C:\fakepath\... string
  • React Native support - the same API on web and mobile (via gform-react/native); no adapters, no separate mental model

The honest part

gform-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.

If you're happy with your current setup, you probably don't need it.

But 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.

Top comments (0)