react-ts-form

Build maintainable, typesafe forms faster

README

banner

Build maintainable, typesafe forms faster 🏃💨

@ts-react/form handles the boilerplate involved when building forms using zod and react-hook-form without sacrificing customizability.


Features


- 🥹 Automatically generate typesafe forms with zod schemas
- 📎 Eliminate repetitive jsx and zod/rhf boilerplate
- 🎮 Full control of components via typesafe props
- 🤯 Headless UI that can render any react component
- ❤️ Quality Of Life / Productivity features not feasible in vanillazod and react-hook-form
- 🤌🏻 Very tiny utility library (~3kb gzipped)
- 👀 Great test coverage



Quick Start


Installation


Make sure you have "strict": true in your tsconfig.json compilerOptions and make sure you set your editors typescript version to v4.9 (or intellisense won't be as reliable).

Install package and dependencies with your preferred package manager:

  1. ```sh
  2. yarn add @ts-react/form

  3. # required peer dependencies
  4. yarn add zod react-hook-form @hookform/resolvers
  5. ```

Usage


Create a zod-to-component mapping to map zod schemas to your components then create your form with createTsForm (typically once per project):

  1. ```tsx
  2. // create the mapping
  3. const mapping = [
  4.   [z.string(), TextField],
  5.   [z.boolean(), CheckBoxField],
  6.   [z.number(), NumberField],
  7. ] as const; // 👈 `as const` is necessary

  8. // A typesafe React component
  9. const MyForm = createTsForm(mapping);
  10. ```

Now just create form schemas with zod and pass them to your form:

  1. ```tsx
  2. const SignUpSchema = z.object({
  3.   email: z.string().email("Enter a real email please."), // renders TextField
  4.   password: z.string(),
  5.   address: z.string(),
  6.   favoriteColor: z.enum(["blue", "red", "purple"]), // renders DropDownSelect and passed the enum values
  7.   isOver18: z.boolean(), // renders CheckBoxField
  8. });

  9. function MyPage() {
  10.   function onSubmit(data: z.infer<typeof SignUpSchema>) {
  11.     // gets typesafe data when form is submitted
  12.   }

  13.   return (
  14.     <MyForm
  15.       schema={SignUpSchema}
  16.       onSubmit={onSubmit}
  17.       renderAfter={() => <button type="submit">Submit</button>}
  18.       // optional typesafe props forwarded to your components
  19.       props={{
  20.         email: {
  21.           className: "mt-2",
  22.         },
  23.       }}
  24.     />
  25.   );
  26. }
  27. ```

That's it! Adding a new field to your form just means adding an additional property to the schema.

It's recommended but not required that you create a custom form component to handle repetitive stuff (like rendering the submit button).

Creating Input Components


Form components can be any react component. The useTsController() hook allows you to build your components with the form state:

  1. ```tsx
  2. function TextField() {
  3.   const { field, error } = useTsController<string>();
  4.   return (
  5.     <>
  6.       <input
  7.         value={field.value ? field.value : ""} // conditional to prevent "uncontrolled to controlled" react warning
  8.         onChange={(e) => {
  9.           field.onChange(e.target.value);
  10.         }}
  11.       />
  12.       {error?.errorMessage && <span>{error?.errorMessage}</span>}
  13.     </>
  14.   );
  15. }
  16. ```

@ts-react/form will magically connecting your component to the appropriate field with this hook. You can also receive the control and name as props, if you prefer:

  1. ```tsx
  2. function TextField({ control, name }: { control: Control<any>; name: string }) {
  3.   const { field, fieldState } = useController({ name, control });
  4.   //...
  5. }
  6. ```

This approach is less typesafe than useTsController.

If you want the control, name, or other @ts-react/form data to be passed to props with a different name check out prop forwarding.

Docs



TypeSafe Props


Based on your component mapping, @ts-react/form knows which field should receive which props:

  1. ```tsx
  2. const mapping = [
  3.   [z.string(), TextField] as const,
  4.   [z.boolean(), CheckBoxField] as const,
  5. ] as const;

  6. //...
  7. const Schema = z.object({
  8.   name: z.string(),
  9.   password: z.string(),
  10.   over18: z.boolean(),
  11. })
  12. //...
  13. <MyForm
  14.   props={{
  15.     name: {
  16.       // TextField props
  17.     },
  18.     over18: {
  19.       // CheckBoxField props
  20.     }
  21.   }}
  22. />
  23. ```

@ts-react/form is also aware of which props are required, so it will make sure you always pass required props to your components:


Here we get an error because `` requires the prop `required`, and we didn't pass it.

  1. ```tsx
  2. return (
  3.   <Form
  4.     schema={FormSchema}
  5.     onSubmit={() => {}}
  6.     props={{
  7.       field: {
  8.         required: "Fixed!",
  9.       },
  10.     }}
  11.   />
  12. );
  13. ```

Fixed! We get all the same typesafety of writing out the full jsx.

Error Handling


It's important to always display errors to your users when validation fails.

Accessing Error Messages in your component


@ts-react/form also returns an error object that's more accurately typed than react-hook-forms's that you can use to show errors:

  1. ```tsx
  2. function MyComponent() {
  3.   const { error } = useTsController<string>();

  4.   return (
  5.     <div>
  6.       // ...
  7.       // Normally we conditionally render error messages
  8.       {error && <span>{error.errorMessage}</span>}
  9.     </div>
  10.   )
  11. }
  12. ```

Writing error messages


Zod schemas make it very easy to create validation steps for your form while also providing an easy way to pass error messages when those steps fail:

  1. ```tsx
  2. z.object({
  3.   email: z.string().email("Invalid email"),
  4.   password: z.string()
  5.     .min(1, "Please enter a password.")
  6.     .min(8, "Your password must be at least 8 characters in length")
  7. )}
  8. ```

In the above schema, the email field is validated as an email because we've called .email() on the string schema, the message "Invalid email" will be put into the form state if the user tries to submit. To learn more about the different types of validations you can perform you should consult the zod documentation (since zod schemas are what generates the errors for this library).

Revalidation

The default behavior for this library is that errors will be shown once the user tries to submit, and fields will be revalidated as the value changes (as soon as the user enters a valid email the error message dissapears). Generally this works well but you may want to use some other validation behavior. Check out the react hook form docs and pass a customuseForm to your forms form prop:

  1. ```tsx
  2. const form = useForm<z.infer<typeof MyFormSchema>>({
  3.   resolver: zodResolver(MyFormSchema),
  4.   revalidateMode: "onSubmit" // now the form revalidates on submit
  5. });

  6. return (
  7.   <Form
  8.     //...
  9.     form={form}
  10.   />
  11. )
  12. ```

For more information about dealing with errors (IE imperatively resetiting errors), check out the hook form docs

Dealing with collisions


Some times you want multiple types of for the same zod schema type. You can deal with collisions using createUniqueFieldSchema:

  1. ```tsx
  2. const MyUniqueFieldSchema = createUniqueFieldSchema(
  3.   z.string(),
  4.   "aUniqueId" // You need to pass a string ID, it can be anything but has to be set explicitly and be unique.
  5. );

  6. const mapping = [
  7.   [z.string(), NormalTextField] as const,
  8.   [MyUniqueFieldSchema, UltraTextField] as const,
  9. ] as const;

  10. const MyFormSchema = z.object({
  11.   mapsToNormal: z.string(), // renders as a NormalTextField component
  12.   mapsToUnique: MyUniqueTextFieldSchema, // renders as a UltraTextField component.
  13. });
  14. ```

Handling Optionals


@ts-react/form will match optionals to their non optional zod schemas:

  1. ```tsx
  2. const mapping = [[z.string(), TextField]] as const;

  3. const FormSchema = z.object({
  4.   optionalEmail: z.string().email().optional(), // renders to TextField
  5.   nullishZipCode: z.string().min(5, "5 chars please").nullish(), // renders to TextField
  6. });
  7. ```

Your zod-component-mapping should not include any optionals. If you want a reusable optional schema, you can do something like this:

  1. ```tsx
  2. const mapping = [[z.string(), TextField]] as const;

  3. export const OptionalTextField = z.string().optional();
  4. ```

Accessing useForm state


Sometimes you need to work with the form directly (such as to reset the form from the parent). In these cases, just pass the react-hook-form useForm() result to your form:

  1. ```tsx
  2. function MyPage() {
  3.   // Need to type the useForm call accordingly
  4.   const form = useForm<z.infer<typeof FormSchema>>();
  5.   const { reset } = form;
  6.   return (
  7.     <Form
  8.       form={form}
  9.       schema={FormSchema}
  10.       // ...
  11.     />
  12.   );
  13. }
  14. ```

Complex field types


You can use most any zod schema and have it map to an appropriate component:

  1. ```tsx
  2. function AddressEntryField() {
  3.   const {field: {onChange, value}, error} = useTsController<z.infer<typeof AddressSchema>>();
  4.   const street = value?.street;
  5.   const zipCode = value?.zipCode;
  6.   return (
  7.     <div>
  8.       <input
  9.         value={street}
  10.         onChange={(e)=>{
  11.           onChange({
  12.             ...value,
  13.             street: e.target.value,
  14.           })
  15.         })
  16.       />
  17.       {error?.street && <span>{error.street.errorMessage}</span>}
  18.       <input
  19.         value={zipCode}
  20.         onChange={(e)=>{
  21.           onChange({
  22.             ...value,
  23.             zipCode: e.target.value
  24.           })
  25.         }}
  26.       />
  27.       {error?.zipCode && <span>{error.zipCode.errorMessage}</span>}
  28.     </div>
  29.   )
  30. }

  31. const AddressSchema = z.object({
  32.   street: z.string(),
  33.   zipCode: z.string(),
  34. });

  35. const mapping = [
  36.   [z.string, TextField] as const,
  37.   [AddressSchema, AddressEntryField] as const,
  38. ] as const;

  39. const FormSchema = z.object({
  40.   name: z.string(),
  41.   address: AddressSchema, // renders as AddressInputComponent
  42. });
  43. ```

This allows you to build stuff like this when your designer decides to go crazy:



Adding non input components into your form


Some times you need to render components in between your fields (maybe a form section header). In those cases there are some extra props that you can pass to your fields beforeElement or afterElement which will render a ReactNode before or after the field:

  1. ```tsx
  2. <MyForm
  3.   schema={z.object({
  4.     field: z.string(),
  5.   })}
  6.   props={{
  7.     field: {
  8.       beforeElement: <span>Renders Before The Input</span>,
  9.       afterElement: <span>Renders After The Input</span>,
  10.     },
  11.   }}
  12. />
  13. ```

Customizing form components


By default your form is just rendered with a "form" tag. You can pass props to it via formProps:

  1. ```tsx
  2. <MyForm
  3.   formProps={{
  4.     ariaLabel: "label",
  5.   }}
  6. />
  7. ```

You can also provide a custom form component as the second parameter to createTsForm options if you want, it will get passed an onSubmit function, and it should also render its children some where:

  1. ```tsx
  2. const mapping = [
  3.   //...
  4. ] as const

  5. function MyCustomFormComponent({
  6.   children,
  7.   onSubmit,
  8.   aThirdProp,
  9. }:{
  10.   children: ReactNode,
  11.   onSubmit: ()=>void,
  12.   aThirdProp: string,
  13. }) {
  14.   return (
  15.     <form onSubmit={onSubmit}>
  16.       <img src={"https://picsum.photos/200"} className="w-4 h-4">
  17.       {/* children is you form field components */}
  18.       {children}
  19.       <button type="submit">submit</button>
  20.     </form>
  21.   )
  22. }
  23. // MyCustomFormComponent is now being used as the container instead of the default "form" tag.
  24. const MyForm = createTsForm(mapping, {FormComponent: MyCustomFormComponent});

  25. <MyForm
  26.   formProps={{
  27.     // formProps is typesafe to your form component's props (and will be required if there is
  28.     // required prop).
  29.     aThirdProp: "prop"
  30.   }}
  31. />
  32. ```

Manual Form Submission


The default form component as well as a custom form component (if used) will automatically be passed the onSubmit function.
Normally, you'll want to pass a button to the renderAfter or renderBefore prop of the form:

  1. ```tsx
  2. <MyForm renderAfter={() => <button type="submit">Submit</button>} />
  3. ```

For React Native, or for other reasons, you will need to call submit explicitly:

  1. ```tsx
  2. <MyForm
  3.   renderAfter={({ submit }) => (
  4.     <TouchableOpacity onPress={submit}>
  5.       <Text>Submit</Text>
  6.     </TouchableOpacity>
  7.   )}
  8. />
  9. ```

React Native Usage


For now React Native will require you to provide your own custom form component. The simplest way to do it would be like:

  1. ```tsx
  2. const FormContainer = ({ children }: { children: ReactNode }) => (
  3.   <View>{children}</View>
  4. );

  5. const mapping = [
  6.   //...
  7. ] as const;

  8. const MyForm = createTsForm(mapping, { FormComponent: FormContainer });
  9. ```

Default values


You can provide typesafe default values like this:

  1. ```tsx
  2. const Schema = z.object({
  3.   string: z.string(),
  4.   num: z.number()
  5. })
  6. //...
  7. <MyForm
  8.   schema={Schema}
  9.   defaultValues={{
  10.     string: 'default',
  11.     num: 5
  12.   }}
  13. />
  14. ```

Prop Forwarding


Prop forwarding is an advanced feature that allows you to control which props @ts-react/form forward to your components as well as the name.

You probably don't need to use this especially when building a project from scratch, but it can allow more customization. This can be useful for integrating with existing components, or for creating a selection of components that can be used both with and without @ts-react/form.

For example, if I wanted the react hook form control to be forwarded to a prop named floob I would do:

  1. ```tsx
  2. const propsMap = [
  3.   ["control", "floob"] as const,
  4.   ["name", "name"] as const,
  5. ] as const;

  6. function TextField({ floob, name }: { floob: Control<any>; name: string }) {
  7.   const { field, fieldState } = useController({ name, control: floob });
  8. }

  9. const componentMap = [[z.string(), TextField] as const] as const;

  10. const MyForm = createTsForm(componentMap, {
  11.   propsMap: propsMap,
  12. });
  13. ```

Props that are included in the props map will no longer be passable via the props prop of the form. So if you don't want to forward any props to your components (and prefer just using hooks), you can pass an empty array. _Any data that's not included in the props map will no longer be passed to your components_


❤️ Quality of Life / Productivity ❤️


These allow you to build forms even faster by connecting zod schemas directly to react state. These features are opt-in, it's possible to do the things in this section via props but these approaches may be faster / easier.

Quick Labels / Placeholders


@ts-react/form provides a way to quickly add labels / placeholders via zod's .describe() method:

  1. ```tsx
  2. const FormSchema = z.object({
  3.   // label="Field One", placeholder="Please enter field one...."
  4.   fieldOne: z.string().describe("Field One // Please enter field one..."),
  5. });
  6. ```

The // syntax separates the label and placeholder. @ts-react/form will make these available via the useDescription() hook:

  1. ```ts
  2. function TextField() {
  3.   const { label, placeholder } = useDescription();
  4.   return (
  5.     <>
  6.       <label>{label}</label>
  7.       <input placeholder={placeholder} />
  8.     </>
  9.   );
  10. }
  11. ```

This is just a quicker way to pass labels / placeholders, but it also allows you to reuse placeholder / labels easily across forms:

  1. ```tsx
  2. const MyTextFieldWithLabel = z.string().describe("label");

  3. const FormSchemaOne = z.object({
  4.   field: MyTextFieldWithLabel,
  5. });

  6. const FormSchemaTwo = z.object({
  7.   field: MyTextFieldWithLabel,
  8. });
  9. ```

If you prefer, you can just pass label and placeholder as normal props via props.

TypeScript versions


Older versions of typescript have worse intellisense and may not show an error in your editor. Make sure your editors typescript version is set to v4.9 plus. The easiest approach is to upgrade your typescript globally if you haven't recently:

  1. ```sh
  2. sudo npm -g upgrade typescript
  3. ```

Or, in VSCode you can do (Command + Shift + P) and search for "Select Typescript Version" to change your editors Typescript Version:

Screenshot 2023-01-01 at 10 55 11 AM

Note that you can still compile with older versions of typescript and the type checking will work.

Limitations


- Doesn't support class components
- @ts-react/form allows you to pass props to your components and render elements in between your components, which is good for almost all form designs out there. Some designs may not be easily achievable. For example, if you need a container around multiple sections of your form, this library doesn't allow splitting child components into containers at the moment. (Though if it's a common-enough use case and you'd like to see it added, open an issue!)