gform-react
Back to Guides

Core Concepts

GForm, formKey, the GInput element render prop, and how form state works.

Everything in gform-react revolves around three ideas: a typed form interface, the GForm container, and GInput fields identified by a formKey. This page explains how they fit together.

The form interface

You describe your form as a TypeScript interface - keys are field names (formKeys), values are the field value types. This single type drives the typing of state, toRawData(), and validators.

interface AddGoalForm {
  title: string;
  deadline: string;
  status: "on-track" | "ahead";
}

GForm and formKey

GForm<T> is the container. Each GInput declares a formKey that must be a key of T. The formKey is also used as the input's name, which is how Server Actions read values from FormData.

<GForm<AddGoalForm> validators={validators} onSubmit={handleSubmit}>
  <GInput formKey="title" required element={/* … */} />
</GForm>
Keep validators stable

Pass validators by a stable reference - declare it at module level, or wrap it in useMemo. A new object on every render re-registers the fields and defeats the optimization.

The element render prop

By default - when you don't pass element - GInput renders a native <input>, fully wired: value, handlers, name, and aria attributes are all applied for you. For simple fields that's all you need.

To render anything else, provide an element function that receives:

  • input - the field's live state (value, error, errorText, formKey, …).
  • props - the props you must spread onto your control (<input {...props} />): gform's wiring plus any extra props you set on GInput (see pass-through props).

This is what lets gform-react work with any UI: a native <input> with your own markup around it, a <select>, <textarea>, or a component from MUI, PrimeReact, etc. The full prop table and per-type value typing live on the GInput page.

<GInput
  formKey="title"
  required
  element={(input, props) => (
    <label>
      <span>Title</span>
      <input {...props} />
      {input.error && <small>{input.errorText}</small>}
    </label>
  )}
/>

Every other prop passes through

GInput accepts any attribute the rendered control does. Everything you put on GInput that gform doesn't consume itself (formKey, element, validatorKey, fetch, fetchDeps, debounce) is forwarded into props - the {...props} spread delivers it to your control. So attributes like placeholder, autoComplete, className, or id go on GInput, not on the element inside the render prop:

<GInput
  formKey="username"
  required
  placeholder="username"
  autoComplete="username"
  element={(input, props) => (
    <label>
      {/* props carries placeholder + autoComplete (and the required constraint) */}
      <input {...props} />
      {input.error && <small>{input.errorText}</small>}
    </label>
  )}
/>

This keeps a field's entire configuration in one place on GInput and lets you reuse the same element function across fields.

A few props are managed by gform and can't be overridden this way: name is always the formKey, value and checked are controlled by form state, and ref, aria-invalid, and aria-required are injected. Handlers are chained, not replaced - an onChange, onBlur, or onInvalid you pass to GInput runs after gform's own handler.

Typed for any control

props is typed so it can be spread onto an <input>, <select>, or <textarea> without a cast:

<GInput
  formKey="status"
  value="on-track" // initial value
  element={(input, props) => (
    <select {...props}>
      <option value="on-track">On track</option>
      <option value="ahead">Ahead</option>
    </select>
  )}
/>
onChange and ref are injected

props already includes the onChange and ref gform needs - don't override them in the spread. If you need your own handler, pass onChange/onBlur to GInput itself and gform chains it after its own.

Form state

GForm can take a function child that receives the live state. Use it to read validity, read values, or disable the submit button.

<GForm<AddGoalForm> validators={validators} onSubmit={handleSubmit}>
  {(state) => (
    <>
      <GInput formKey="title" required element={/* … */} />
      {/* disable from form state - not manual value checks */}
      <button disabled={state.isInvalid}>Add Goal</button>
    </>
  )}
</GForm>

The state object exposes:

MemberDescription
state.<formKey>.valueThe typed value of a single field.
state.<formKey>.errorWhether the field currently has a validation error.
state.<formKey>.errorTextThe current error message.
state.isValid / state.isInvalidSingle source of truth for whole-form validity.
state.toRawData(options?){ [formKey]: value } for the whole form.
state.toFormData(options?)A FormData instance (includes file fields).
state.toURLSearchParams(options?)A URLSearchParams instance.
state.checkValidity()Runs all validators and returns whether the form is valid.
state.dispatchChanges(changes)Programmatically update one or more fields.

Reading the submitted data

Use toRawData() to read the whole form - no casts needed. Spread it to add extra fields:

const submit = (state: GFormState<AddGoalForm>) => {
  const goal = { ...state.toRawData(), progress: 0 };
  save(goal);
};

You can also transform individual fields inline:

state.toRawData({ transform: { title: (v) => v.trim() } });

onSubmit semantics

<GForm
  validators={validators}
  onSubmit={(state, e) => {
    e.preventDefault(); // gform does NOT auto-preventDefault
    save(state.toRawData()); // only runs when the form is valid
  }}
>

Two important guarantees:

  • onSubmit only fires when the form is valid. An invalid form never reaches your handler.
  • gform does not call e.preventDefault() for you. This is deliberate - it's what enables native <form> submission and Next.js Server Actions. Call preventDefault() yourself when you handle submission in JavaScript. (For Server Actions you use action instead and must not preventDefault - see Server Actions.)

Programmatic updates

To update a field from code and optionally re-run validation:

state.title.dispatchChanges({ value: "New title" }, { validate: true });

Next