react-hook-native-forms
v0.1.0
Published
React Native form components built on react-hook-form
Maintainers
Readme
react-hook-native-forms
A small, React Native–first form layer built on top of
react-hook-form. Instead of wiring
Controller / useController yourself, you compose two components:
// The app owns the form instance; <Form> only publishes it.
const form = useForm({ defaultValues: { email: '' } });
<Form form={form} onSubmit={onSubmit}>
<Form.Item
name="email"
label="Email"
onChangePropName="onChangeText"
rules={{ required: 'Email is required' }}
>
<TextInput placeholder="[email protected]" />
</Form.Item>
</Form>;The package deliberately does not re-export the react-hook-form API.
Import anything else you need (resolvers, useWatch, …) from react-hook-form
directly.
Features
<Form>publishes theuseForm()instance you own to its descendants through the officialFormProviderplus a package context, sodefaultValues,modeandresolverstay under the app's control.<Form.Item>registers its field withuseController, clones the single child input and injects the field props — no manualControllerboilerplate.- Optional labels and automatic validation messages rendered as React Native
Text. - Zero runtime dependencies;
react,react-nativeandreact-hook-formstay peer dependencies so your app keeps a single copy of each. - Ships ESM, CommonJS and type declarations, and is marked
"sideEffects": falsefor tree-shaking.
Installation
npm install react-hook-native-formsThe following packages must be installed by the consuming app (they are peer dependencies and are never bundled):
| Peer dependency | Supported range |
| ----------------- | --------------- |
| react | ^18 \|\| ^19 |
| react-native | >=0.70 |
| react-hook-form | ^7.0.0 |
Quick start
import { Pressable, Text, TextInput } from 'react-native';
import { useForm } from 'react-hook-form';
import { Form, useFormHandle } from 'react-hook-native-forms';
type LoginValues = {
email: string;
password: string;
};
// React Native has no native form element, so a descendant triggers the submit.
function SubmitButton() {
const { handleSubmit } = useFormHandle<LoginValues>();
return (
<Pressable onPress={() => void handleSubmit()}>
<Text>Sign in</Text>
</Pressable>
);
}
export function LoginScreen() {
// The screen owns the instance, so its `defaultValues` and `mode` are explicit.
const form = useForm<LoginValues>({
defaultValues: { email: '', password: '' },
});
return (
<Form<LoginValues>
form={form}
onSubmit={(values) => {
console.log(values);
}}
>
<Form.Item<LoginValues>
name="email"
label="Email"
onChangePropName="onChangeText"
rules={{
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Enter a valid email address',
},
}}
>
<TextInput autoCapitalize="none" keyboardType="email-address" />
</Form.Item>
<Form.Item<LoginValues>
name="password"
label="Password"
onChangePropName="onChangeText"
rules={{
required: 'Password is required',
minLength: { value: 8, message: 'At least 8 characters' },
}}
>
<TextInput secureTextEntry />
</Form.Item>
<SubmitButton />
</Form>
);
}onSubmit is only called once validation passes; otherwise the messages are
rendered under the matching fields.
API
<Form>
Publishes the form instance you created with useForm() to every descendant and
renders a View wrapping its children.
| Prop | Type | Default | Description |
| ---------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| form | UseFormReturn<TFieldValues> | — | Required. The useForm() instance owned by the consumer. |
| onSubmit | SubmitHandler<TFieldValues> | — | Called with the values once validation succeeds. When omitted, the handleSubmit exposed by useFormHandle does nothing. |
| children | ReactNode | — | Required. Form content, typically Form.Item elements. |
| style | StyleProp<ViewStyle> | — | Style applied to the wrapping View. |
Form never calls useForm() itself and never forwards useForm options.
Configure defaultValues, mode, criteriaMode and resolver where you create
the instance:
const form = useForm<LoginValues>({
defaultValues: { email: '', password: '' },
mode: 'onBlur',
});<Form.Item>
Registers a field and binds it to exactly one child element.
| Prop | Type | Default | Description |
| ------------------ | -------------------------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| name | FieldPath<TFieldValues> | — | Required. Path of the field in the form values. |
| children | ReactElement | — | Required. Exactly one input element. |
| rules | UseControllerProps<TFieldValues, TName>['rules'] | — | Validation rules (required, pattern, minLength, validate, …). |
| defaultValue | FieldPathValue<TFieldValues, TName> | — | Initial value for this field, forwarded to useController. Form-wide defaults belong in useForm({ defaultValues }). |
| label | ReactNode | — | Rendered above the input inside a Text. |
| showError | boolean | true | Set to false to suppress the validation message. |
| styles | { container?: StyleProp<ViewStyle>; label?: StyleProp<TextStyle>; error?: StyleProp<TextStyle> } | — | Styles for the wrapping View, the label Text and the error Text (red by default). |
| onChangePropName | string | 'onChange' | Name of the child prop that receives field.onChange, e.g. onChangeText. |
| valuePropName | string | 'value' | Name of the child prop that receives the field value, e.g. checked. |
| mapFieldProps | (field, meta) => Record<string, unknown> \| undefined | — | Escape hatch: extra props merged into the child after the injected ones. |
Injected child props
Form.Item spreads the child's own props first, then injects:
| Prop | Source |
| ---------- | ---------------- |
| name | the name prop |
| value | field.value |
| onChange | field.onChange |
| onBlur | field.onBlur |
The value / onChange prop names are configurable through valuePropName
and onChangePropName. React Native's TextInput reports its text through
onChangeText and gives onChange a native event without the DOM-style
target.value that react-hook-form reads, so bind it with
onChangePropName="onChangeText" as the example app does.
Injected props win over props already present on the child. mapFieldProps
covers inputs with a different contract again.
useFormHandle<TFieldValues>()
Reads the enclosing Form from context. Returns { control, handleSubmit },
where handleSubmit() already has the Form's onSubmit bound and runs
validation first, so a descendant button can simply call it.
The hook is named useFormHandle rather than useFormContext on purpose:
react-hook-form already exports a hook called useFormContext, and the two
return different things. Import useFormContext from react-hook-form when you
need the raw form instance; import useFormHandle from this package when you
want the pre-bound submit.
Using Form.Item or useFormHandle outside of a <Form> throws a descriptive
error naming the hook.
Other exports
FormItem is exported on its own as well, which is handy when Form.Item would
collide with another Item in scope. The public types FormProps,
FormItemProps, FormContextValue, FormItemFieldProps, FormItemFieldState
and FormSubmitHandler are exported for building typed wrappers around the
components.
Notes and limitations
nameis not inferred from the ancestorForm. React context erases the generic, soForm.Itemcannot know the parent's field values type. Pass the generic explicitly (<Form.Item<LoginValues> name="email">) to get autocompletion and a compile-time check ofname. AcreateForm<T>()factory is a possible future enhancement.- Child element required.
Form.Itemneeds exactly one element it can clone; passing a string,nullor multiple children throws. - React Native only. The components render
View/Textand inject React Native input props; there is noreact-dombuild.
Example app
A runnable Expo login form lives in example. It consumes the built
package, so build first:
npm run build
cd example
npm install
npm startThe example is not published: the package files field restricts the
tarball to dist, and example is excluded from the library tsconfig.json.
Development
Node.js 22.12+ is required for the toolchain (Vite 8, Vitest 5,
Changesets 3). CI runs npm run verify on Node 22 and 24.
npm install
npm run verify # format:check → typecheck → lint → test → buildRun the individual steps when you need them:
npm run format:check # prettier --check .
npm run typecheck # tsc --noEmit
npm run lint # ESLint
npm run test # Vitest
npm run build # tsup → dist (ESM + CJS + .d.ts)
npm run format # prettier --write .The example app is checked from the root as well; it consumes the built package, so build before running these:
npm run build
npm --prefix example install
npm run typecheck:example # tsc --noEmit in example/
npm run lint:example # expo lint in example/CI runs npm run verify on Node 22 and 24, plus a separate example job that
builds the library and then typechecks and lints the example.
The test suite runs against react-native-web in jsdom (see
vitest.config.ts), so the React Native components are exercised in a DOM
environment.
Breaking change: useFormContext → useFormHandle
The package hook was renamed so it no longer shadows react-hook-form's own
useFormContext; import useFormHandle from react-hook-native-forms instead.
There is no deprecated alias, and the API now also requires the consumer to own
the useForm() instance and pass it as form.
Release workflow
Versioning and the changelog are managed with Changesets:
npx changeset # describe the change (choose the bump type)
npx changeset version # bump versions and update CHANGELOG.md
npm run release # changeset publishPublishing is still a manual step: the repository has no release workflow yet,
and prepublishOnly only re-runs npm run verify before npm publish.
