Behive Tech Registry

form-provider

v0.1.2

The single place `react-hook-form` is imported directly in this repo. Everything else should import form state/control from here instead of `react-hook-form` — that keeps the underlying library swappable from one spot later.

pnpm add @behivetech/forms.form-provider

registry/behivetech/forms/form-provider

form-provider.tsx
1import { type ReactNode } from "react";
2import {
3 FormProvider as RHFFormProvider,
4 type FieldValues,
5 type UseFormReturn,
6} from "react-hook-form";
7
8export interface FormProviderProps<
9 TFieldValues extends FieldValues = FieldValues,
10> {
11 /** The return value of `useForm()` (re-exported from this package) */
12 methods: UseFormReturn<TFieldValues>;
13 /** Descendants that will consume the form context via `useFormContext()` */
14 children: ReactNode;
15}
16
17/**
18 * Thin wrapper around react-hook-form's `FormProvider`. Import this (and
19 * `useForm`, `Controller`, etc.) from this package instead of importing
20 * `react-hook-form` directly anywhere else — this is the one place that
21 * knows about the underlying form library, so it can be swapped later
22 * without touching every consumer.
23 */
24export const FormProvider = <TFieldValues extends FieldValues = FieldValues>({
25 methods,
26 children,
27}: FormProviderProps<TFieldValues>) => {
28 return <RHFFormProvider {...methods}>{children}</RHFFormProvider>;
29};
form.tsx
1import { type FormHTMLAttributes, type ReactNode } from "react";
2import {
3 type FieldValues,
4 type SubmitHandler,
5 type UseFormReturn,
6} from "react-hook-form";
7import { FormProvider } from "./form-provider.js";
8
9export interface FormProps<
10 TFieldValues extends FieldValues = FieldValues,
11> extends Omit<FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
12 /** The return value of `useForm()` */
13 methods: UseFormReturn<TFieldValues>;
14 /** Called with the validated values when the form is submitted */
15 onSubmit: SubmitHandler<TFieldValues>;
16 /** Form content, typically FormField-wrapped controls */
17 children: ReactNode;
18}
19
20/**
21 * Convenience wrapper for the common case: a `<form>` whose submit is wired
22 * to react-hook-form's `handleSubmit`, with `FormProvider` already applied
23 * so descendants can use `useFormContext()`. For editing UIs with no submit
24 * moment (live/instant field-by-field updates), use the bare atoms +
25 * `FormField` instead — this component assumes a real submit action exists.
26 */
27export const Form = <TFieldValues extends FieldValues = FieldValues>({
28 methods,
29 onSubmit,
30 children,
31 ...rest
32}: FormProps<TFieldValues>) => {
33 return (
34 <FormProvider methods={methods}>
35 <form {...rest} onSubmit={methods.handleSubmit(onSubmit)}>
36 {children}
37 </form>
38 </FormProvider>
39 );
40};
index.ts
1export { FormProvider } from "./form-provider.js";
2export type { FormProviderProps } from "./form-provider.js";
3
4export { Form } from "./form.js";
5export type { FormProps } from "./form.js";
6
7export {
8 useForm,
9 useFormContext,
10 useController,
11 useFormState,
12 useWatch,
13 Controller,
14} from "react-hook-form";
15export type {
16 UseFormReturn,
17 UseFormProps,
18 FieldValues,
19 SubmitHandler,
20 Control,
21 ControllerProps,
22 ControllerRenderProps,
23 ControllerFieldState,
24 FieldPath,
25 RegisterOptions,
26 FieldErrors,
27} from "react-hook-form";
form-provider.composition.tsx
1import { FormField } from "@behivetech/forms.form-field";
2import { TextField } from "@behivetech/atoms.text-field";
3import { Button } from "@behivetech/atoms.button";
4import { Form, FormProvider, useForm } from "./index.js";
5
6interface DemoValues {
7 email: string;
8}
9
10export const BasicFormProvider = () => {
11 const methods = useForm<DemoValues>({ defaultValues: { email: "" } });
12 const { register, handleSubmit } = methods;
13
14 return (
15 <FormProvider methods={methods}>
16 <form onSubmit={handleSubmit((values) => console.log(values))}>
17 <FormField label="Email" htmlFor="email">
18 <TextField id="email" {...register("email")} />
19 </FormField>
20 </form>
21 </FormProvider>
22 );
23};
24
25export const BasicForm = () => {
26 const methods = useForm<DemoValues>({ defaultValues: { email: "" } });
27 const { register } = methods;
28
29 return (
30 <Form methods={methods} onSubmit={(values) => console.log(values)}>
31 <FormField label="Email" htmlFor="email-form">
32 <TextField id="email-form" {...register("email")} />
33 </FormField>
34 <Button type="submit" variant="primary" size="sm">
35 Save
36 </Button>
37 </Form>
38 );
39};
form-provider.spec.tsx
1import { render, screen, renderHook } from "@testing-library/react";
2import { FormProvider, useForm, useFormContext } from "./index.js";
3
4function Consumer() {
5 const { register } = useFormContext();
6 return <input {...register("email")} placeholder="email" />;
7}
8
9describe("FormProvider", () => {
10 it("renders children", () => {
11 const { result } = renderHook(() => useForm());
12 render(
13 <FormProvider methods={result.current}>
14 <span>Hello</span>
15 </FormProvider>,
16 );
17 expect(screen.getByText("Hello")).toBeTruthy();
18 });
19
20 it("makes form methods available to descendants via useFormContext", () => {
21 const { result } = renderHook(() => useForm());
22 render(
23 <FormProvider methods={result.current}>
24 <Consumer />
25 </FormProvider>,
26 );
27 expect(screen.getByPlaceholderText("email")).toBeTruthy();
28 });
29});
form.spec.tsx
1import { render, screen, renderHook } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Form, useForm, useFormContext } from "./index.js";
4
5function Consumer() {
6 const { register } = useFormContext();
7 return <input {...register("email")} placeholder="email" />;
8}
9
10describe("Form", () => {
11 it("renders children", () => {
12 const { result } = renderHook(() => useForm());
13 render(
14 <Form methods={result.current} onSubmit={() => {}}>
15 <span>Hello</span>
16 </Form>,
17 );
18 expect(screen.getByText("Hello")).toBeTruthy();
19 });
20
21 it("makes form methods available to descendants via FormProvider", () => {
22 const { result } = renderHook(() => useForm());
23 render(
24 <Form methods={result.current} onSubmit={() => {}}>
25 <Consumer />
26 </Form>,
27 );
28 expect(screen.getByPlaceholderText("email")).toBeTruthy();
29 });
30
31 it("calls onSubmit with the form values when submitted", async () => {
32 const handleSubmit = vi.fn();
33 const { result } = renderHook(() =>
34 useForm({ defaultValues: { email: "a@b.com" } }),
35 );
36 render(
37 <Form methods={result.current} onSubmit={handleSubmit}>
38 <Consumer />
39 <button type="submit">Save</button>
40 </Form>,
41 );
42 await userEvent.click(screen.getByRole("button", { name: "Save" }));
43 expect(handleSubmit).toHaveBeenCalledWith(
44 { email: "a@b.com" },
45 expect.anything(),
46 );
47 });
48});