Skip to content

Get started

Headless React form primitives. useFormControl wires any custom input into a typed form context; <Form> (Web) or <NativeForm> (React Native) collects values; validation rules emit localized error messages. No Formik ceremony, no react-hook-form ref management — just hooks that read and write to a shared form context.

Highlighted features

Headless controls via useFormControl

Build any input shape — text, checkbox, radio, multi-value — by reading value, changeValue, error from the hook. Your component stays in charge of rendering.

Composable validation rules

rules: [requiredRule, emailRule] — combine built-ins or write your own. Error messages render through @mongez/localization (six locales ship: en, ar, fr, es, it, de).

Dot-notation names

name="user.firstName" auto-nests into values.user.firstName on submit. No flat-key gymnastics for nested forms.

Smart submit-button state

useSubmitButton exposes isSubmitting, isValid, hasChanges — drop into your button without prop-drilling form state.

Web + React Native

<Form> renders a real HTML form; <NativeForm> renders a fragment and submits programmatically. Same hooks, same validation, same API.

Form lifecycle events

onChange, onSubmit, onValid, onInvalid — wire side effects without rebuilding a context provider.

Install

Terminal window
npm install @mongez/react-form
# or: yarn add @mongez/react-form
# or: pnpm add @mongez/react-form

Peer dep: react >= 18. Runtime deps install transitively: @mongez/events, @mongez/localization, @mongez/supportive-is, @mongez/reinforcements.

Quick peek

import { Form, useFormControl, requiredRule, emailRule } from "@mongez/react-form";
function TextInput(props) {
const { value, changeValue, error, otherProps } =
useFormControl({ ...props, rules: [requiredRule, emailRule] });
return (
<>
<input value={value} onChange={(e) => changeValue(e.target.value)} {...otherProps} />
{error && <span>{error}</span>}
</>
);
}
<Form onSubmit={({ values }) => api.signup(values)}>
<TextInput name="email" type="email" required />
<button type="submit">Sign up</button>
</Form>

Wrap your own input around useFormControl, drop it inside <Form>, get typed values on submit.

Full setup steps

1. Register validation translations (one-time, at app entry)

Validation rules emit error messages through @mongez/localization. The translation bundles must be registered under the validation namespace before any form mounts. Do this once at the root of the app (typically src/main.tsx or App.tsx):

import { extend } from "@mongez/localization";
import {
enValidationTranslation,
arValidationTranslation,
} from "@mongez/react-form";
extend("en", { validation: enValidationTranslation });
extend("ar", { validation: arValidationTranslation });

Six locales ship: en, ar, fr, es, it, de. Register only those you need.

If this step is skipped, validation still runs but errors appear as raw translation keys (e.g. validation.required) instead of human-readable text.

2. Pick the right form component

  • Web → import Form from @mongez/react-form. Renders an HTML <form> element. Submits via the standard browser submit event.
  • React Native → import NativeForm from @mongez/react-form. Renders a Fragment by default (no host element). Submission is always programmatic.

Both expose the same API — the only differences are the rendered output and how submit is triggered.

3. Minimal first form (Web)

import { Form, useFormControl, requiredRule, type FormControlProps } from "@mongez/react-form";
function TextInput(props: FormControlProps) {
const { value, changeValue, id, error } = useFormControl({
rules: [requiredRule],
...props,
});
return (
<>
<input id={id} value={value} onChange={(e) => changeValue(e.target.value)} />
{error && <span style={{ color: "red" }}>{error}</span>}
</>
);
}
export default function App() {
return (
<Form onSubmit={({ values }) => console.log(values)}>
<TextInput name="firstName" required />
<TextInput name="lastName" />
<button>Submit</button>
</Form>
);
}

The name prop on each TextInput becomes the key in the submitted values object. Dot notation (user.firstName) is supported and produces nested objects.

4. Verify the baseline

After completing steps 1–3, you should be able to:

  • Mount the form, type into both inputs, click Submit.
  • See the values object logged with both names.
  • Submit with an empty first name and see the localized “This input is required” error rendered inline.

If any of those fail, the likely cause is one of:

  • Validation translations not registered → errors show as validation.required text.
  • name prop missing on an input → it won’t be collected into values.
  • <button> placed outside the <Form> → click won’t trigger form submission.

5. SSR projects: pass a static id to every <Form>

In server-rendered apps (Next.js, Remix, Astro, TanStack Start, any setup that renders on the server then hydrates on the client), always pass an explicit, static id to each <Form>:

<Form id="signup" onSubmit={({ values }) => api.signup(values)}>
<TextInput name="email" type="email" required />
<SubmitButton>Sign up</SubmitButton>
</Form>

Why. When you omit id, the form generates one at construction with Math.random() (frm-<random>), and renders it as the <form id="form-frm-…"> attribute. The server and the client each run that random generation independently, so they produce different ids — React reports a hydration mismatch and discards the server markup for that subtree. A static id makes the rendered attribute deterministic (form-signup) and identical on both sides, so hydration is clean.

Rules of thumb:

  • One static id per <Form>, and keep it unique on the page. The id also drives the internal form id and its event prefix (form.<id>), so two forms sharing an id would cross-wire their events.
  • Inputs don’t need this. A control’s id is derived from its name (input-<name>), which is already deterministic — only the <Form> wrapper falls back to a random id, so that’s the only place you must set one.
  • Client-only apps (plain Vite/CRA SPA, React Native via NativeForm) never hydrate server markup, so the random id is harmless there — but setting a static id anyway is a good habit and makes the rendered <form> easier to target in tests and styles.

Where to go next