├── .babelrc
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── README.md
├── package.json
├── pnpm-lock.yaml
├── src
├── index.ts
└── useForm.ts
├── tsconfig.cjs.json
├── tsconfig.esm.json
├── tsconfig.json
└── tsconfig.types.json
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "modules": false
7 | }
8 | ],
9 | "@babel/preset-react"
10 | ],
11 | "env": {
12 | "test": {
13 | "plugins": [
14 | "@babel/plugin-transform-modules-commonjs"
15 | ]
16 | },
17 | "commonjs": {
18 | "presets": [
19 | [
20 | "@babel/preset-env",
21 | {
22 | "modules": "commonjs"
23 | }
24 | ],
25 | "@babel/preset-react"
26 | ]
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS or Editor files
2 | ._*
3 | .DS_Store
4 | Thumbs.db
5 |
6 | # Files that might appear on external disks
7 | .Spotlight-V100
8 | .Trashes
9 |
10 | # Always-ignore extensions
11 | *~
12 | *.diff
13 | *.err
14 | *.log
15 | *.orig
16 | *.pyc
17 | *.rej
18 | *.vsix
19 |
20 | *.sass-cache
21 | *.sw?
22 | *.vi
23 |
24 | yarn.lock
25 |
26 | .merlin
27 | node_modules
28 | lib
29 | es
30 | types
31 | coverage
32 | dist
33 | .next
34 | .vercel
35 | out
36 | .vscode
37 | .turbo
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "singleQuote": true,
4 | "bracketSpacing": true,
5 | "jsxBracketSameLine": true,
6 | "printWidth": 80,
7 | "tabWidth": 2,
8 | "useTabs": false,
9 | "semi": false
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.exclude": {
3 | "**/._*": true,
4 | "**/.DS_Store": true,
5 | "**/Thumbs.db": true,
6 | "**/.Spotlight-V100": true,
7 | "**/.Trashes": true,
8 | "**/*~": true,
9 | "**/*.diff": true,
10 | "**/*.err": true,
11 | "**/*.log": true,
12 | "**/*.orig": true,
13 | "**/*.pyc": true,
14 | "**/*.rej": true,
15 | "**/*.vsix": true,
16 | "**/*.sass-cache": true,
17 | "**/*.sw?": true,
18 | "**/*.vi": true,
19 | "**/yarn.lock": true,
20 | "**/.merlin": true,
21 | "**/node_modules": true,
22 | "**/lib": true,
23 | "**/es": true,
24 | "**/coverage": true,
25 | "**/dist": true,
26 | "**/.next": true,
27 | "**/.vercel": true,
28 | "**/out": true,
29 | "**/.vscode": true,
30 | "**/.turbo": true
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-controlled-form
2 |
3 | A package for creating controlled forms in React with baked in [zod](https://zod.dev) validation.
4 | You own and control the rendered markup and the hook takes care of the state and validation.
5 |
6 |
7 |
8 | ## Installation
9 |
10 | ```sh
11 | # npm
12 | npm i --save react-controlled-form
13 | # yarn
14 | yarn add react-controlled-form
15 | # pnpm
16 | pnpm add react-controlled-form
17 | ```
18 |
19 | ## The Gist
20 |
21 | ```tsx
22 | import * as React from 'react'
23 | import { useForm, FieldProps } from 'react-controlled-form'
24 | import { z, ZodError } from 'zod'
25 |
26 | // create our schema with validation included
27 | const Z_RegisterInput = z.object({
28 | name: z.string().optional(),
29 | email: z.string().email(),
30 | // we can also pass custom messages as a second parameter
31 | password: z
32 | .string()
33 | .min(8, { message: 'Your password next to have at least 8 characters.' }),
34 | })
35 |
36 | type T_RegisterInput = z.infer
37 |
38 | function Form() {
39 | // we create a form by passing the schema
40 | const { useField, handleSubmit, formProps, reset } = useForm(Z_RegisterInput)
41 |
42 | // now we can create our fields for each property
43 | // the field controls the state and validation per property
44 | const name = useField('name')
45 | const email = useField('email')
46 | const password = useField('password')
47 |
48 | function onSuccess(data: T_RegisterInput) {
49 | // do something with the safely parsed data
50 | console.log(data)
51 | // reset the form to its initial state
52 | reset()
53 | }
54 |
55 | function onFailure(error: ZodError) {
56 | console.error(error)
57 | }
58 |
59 | return (
60 |
74 | )
75 | }
76 | ```
77 |
78 | > **Note**: This is, of course, a simplified version and you most likely render custom components to handle labelling, error messages and validation styling.
For such cases, each field also exposes a `props` property that extends the `inputProps` with non-standard HTML attributes.
79 |
80 | ## API Reference
81 |
82 | ### useForm
83 |
84 | The core API that connects the form with a zod schema and returns a set of helpers to manage the state and render the actual markup.
85 |
86 | | Parameter | Type | Default | Description |
87 | | ------------------ | -------------------------------------------- | -------------------------- | -------------------------------------------------- |
88 | | schema | ZodObject | | A valid zod object schema |
89 | | formatErrorMessage | `(error: ZodIssue, name: string) => string` | `(error) => error.message` | A custom formatter that receives the raw zod issue |
90 |
91 | ```ts
92 | import { z } from 'zod'
93 |
94 | const Z_Input = z.object({
95 | name: z.string().optional(),
96 | email: z.string().email(),
97 | // we can also pass custom messages as a second parameter
98 | password: z
99 | .string()
100 | .min(8, { message: 'Your password next to have at least 8 characters.' }),
101 | })
102 |
103 | type T_Input = z.infer
104 |
105 | // usage inside react components
106 | const { useField, handleSubmit, reset, formProps } = useForm(Z_Input)
107 | ```
108 |
109 | #### formatErrorMessage
110 |
111 | The preferred way to handle custom error messages would be to add them to the schema directly.
112 | In some cases e.g. when receiving the schema from an API or when having to localise the error, we can leverage this helper.
113 |
114 | ```ts
115 | import { ZodIssue } from 'zod'
116 |
117 | // Note: the type is ZodIssue and not ZodError since we always only show the first error
118 | function formatErrorMessage(error: ZodIssue, name: string) {
119 | switch (error.code) {
120 | case 'too_small':
121 | return `This field ${name} requires at least ${error.minimum} characters.`
122 | default:
123 | return error.message
124 | }
125 | }
126 | ```
127 |
128 | ### useField
129 |
130 | A hook that manages the field state and returns the relevant HTML attributes to render our inputs.
131 | Also returns a set of helpers to manually update and reset the field.
132 |
133 | | Parameter | Type | Default | Description |
134 | | --------- | ------------------------------ | --------------------- | ----------------------------------------------------------- |
135 | | name | `keyof z.infer` | | The name of the schema property that this field connects to |
136 | | config | [Config](#config) | See [Config](#config) | Initial field data and additional config options |
137 |
138 | #### Config
139 |
140 | | Property | Type | Default | Description |
141 | | ---------------- | ------------------------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
142 | | value | `any` | `''` | Initial value |
143 | | disabled | `boolean` | `false` | Initial disabled state |
144 | | touched | `boolean` | `false` | Initial touched state that indicates whether validation errors are shown or not |
145 | | showValidationOn | `"change"` \| `"blur"` \| `"submit"` | `"submit"` | Which event is used to trigger the touched state |
146 | | parseValue | `(Event) => any` | `(e) => e.target.value` | How the value is received from the input element.
Use `e.target.checked` when working with `` |
147 |
148 | ```ts
149 | const { inputProps, props, errorMessage, update, reset } = useField('email')
150 | ```
151 |
152 | #### inputProps
153 |
154 | Pass these to native HTML `input`, `select` and `textarea` elements.
155 | Use `data-valid` to style the element based on the validation state.
156 |
157 | ```ts
158 | type InputProps = {
159 | name: string
160 | value: any
161 | disabled: boolean
162 | 'data-valid': boolean
163 | onChange: React.ChangeEventHandler
164 | onBlur?: React.KeyboardEventHandler
165 | }
166 | ```
167 |
168 | #### props
169 |
170 | Pass these to custom components that render label and input elements.
171 | Also includes information such as `errorMessage` or `valid` that's non standard HTML attributes and thus can't be passed to native HTML `input` elements directly.
172 |
173 | ```ts
174 | type Props = {
175 | value: any
176 | name: string
177 | valid: boolean
178 | required: boolean
179 | disabled: boolean
180 | errorMessage?: string
181 | onChange: React.ChangeEventHandler
182 | onBlur?: React.KeyboardEventHandler
183 | }
184 | ```
185 |
186 | #### errorMessage
187 |
188 | > **Note**: If you're using [`props`](#props), you already get the errorMessage!
189 |
190 | A string containing the validation message. Only returned if the field is invalid **and** touched.
191 |
192 | #### update
193 |
194 | Programmatically change the data of a field. Useful e.g. when receiving data from an API.
195 | If value is changed, it will automatically trigger re-validation.
196 |
197 | > **Note**: If you know the initial data upfront, prefer to pass it to the `useField` hook directly though.
198 |
199 | ```ts
200 | update({
201 | value: 'Foo',
202 | touched: true,
203 | })
204 | ```
205 |
206 | #### reset
207 |
208 | Resets the field back to its initial field data.
209 |
210 | ```ts
211 | reset()
212 | ```
213 |
214 | ### handleSubmit
215 |
216 | Helper that wraps the native `onSubmit` event on `