Skip to content

Commit de60d5d

Browse files
fulopkovacsautofix-ci[bot]crutchcorn
authored
feat: set field errors from the form validators (#656)
* Set field errors from the form's validators * Fix some failing tests * Fix a test (comment to be added later) * Fix another test * Shameful new line * Update "pnpm-lock.yaml" after the rebase again * ci: apply automated fixes * Fix the sherif tests * Update "pnpm-lock.yaml" after the rebase again * Fix some type errors * Fix a failing test after the rebase * Update the field-errors-from-form-validators examples * Reorganize the code a bit * Update the docs * Update pnpm-lock.yaml * Rebase again * ci: apply automated fixes * Remove the doc pages that will be autogenerated * Clean up the code * Clean up the code around fake timers * Clean up the tests * Update the FieldApi tests * Update the field-errors-from-form-validators example * Fix an example in the docs * chore: fix minor issues with demo * docs: update docs * chore: fix pnpm * ci: apply automated fixes * chore: fix sherif --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Corbin Crutchley <[email protected]>
1 parent 7c1d2a8 commit de60d5d

File tree

16 files changed

+1235
-73
lines changed

16 files changed

+1235
-73
lines changed

docs/framework/react/guides/validation.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,109 @@ export default function App() {
194194
}
195195
```
196196

197+
### Setting field-level errors from the form's validators
198+
199+
You can set errors on the fields from the form's validators. One common use case for this is validating all the fields on submit by calling a single API endpoint in the form's `onSubmitAsync` validator.
200+
201+
```tsx
202+
export default function App() {
203+
const form = useForm({
204+
defaultValues: {
205+
age: 0,
206+
},
207+
validators: {
208+
onSubmitAsync: async ({ value }) => {
209+
// Verify the age on the server
210+
const isOlderThan13 = await verifyAgeOnServer(value.age)
211+
if (!isOlderThan13) {
212+
return {
213+
form: 'Invalid data', // The `form` key is optional
214+
fields: {
215+
age: 'Must be 13 or older to sign',
216+
},
217+
}
218+
}
219+
220+
return null
221+
},
222+
},
223+
})
224+
225+
return (
226+
<div>
227+
<form
228+
onSubmit={(e) => {
229+
e.preventDefault()
230+
e.stopPropagation()
231+
void form.handleSubmit()
232+
}}
233+
>
234+
<form.Field name="age">
235+
{(field) => (
236+
<>
237+
<label htmlFor={field.name}>Age:</label>
238+
<input
239+
id={field.name}
240+
name={field.name}
241+
value={field.state.value}
242+
type="number"
243+
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
244+
/>
245+
{field.state.meta.errors ? (
246+
<em role="alert">{field.state.meta.errors.join(', ')}</em>
247+
) : null}
248+
</>
249+
)}
250+
</form.Field>
251+
<form.Subscribe
252+
selector={(state) => [state.errorMap]}
253+
children={([errorMap]) =>
254+
errorMap.onSubmit ? (
255+
<div>
256+
<em>There was an error on the form: {errorMap.onSubmit}</em>
257+
</div>
258+
) : null
259+
}
260+
/>
261+
{/*...*/}
262+
</form>
263+
</div>
264+
)
265+
}
266+
```
267+
268+
> Something worth mentioning is that if you have a form validation function that returns an error, that error may be overwritten by the field-specific validation.
269+
>
270+
> This means that:
271+
>
272+
> ```jsx
273+
> const form = useForm({
274+
> defaultValues: {
275+
> age: 0,
276+
> },
277+
> validators: {
278+
> onChange: ({ value }) => {
279+
> return {
280+
> fields: {
281+
> age: value.age < 12 ? 'Too young!' : undefined,
282+
> },
283+
> }
284+
> },
285+
> },
286+
> })
287+
>
288+
> // ...
289+
>
290+
> return <form.Field
291+
> name="age"
292+
> validators={{
293+
> onChange: ({ value }) => value % 2 === 0 ? 'Must be odd!' : undefined,
294+
> }}
295+
> />
296+
> ```
297+
>
298+
> Will only show `'Must be odd!` even if the 'Too young!' error is returned by the form-level validation.
299+
197300
## Asynchronous Functional Validation
198301
199302
While we suspect most validations will be synchronous, there are many instances where a network call or some other async operation would be useful to validate against.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// @ts-check
2+
3+
/** @type {import('eslint').Linter.Config} */
4+
const config = {
5+
extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
6+
rules: {
7+
'react/no-children-prop': 'off',
8+
},
9+
}
10+
11+
module.exports = config
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# production
12+
/build
13+
14+
pnpm-lock.yaml
15+
yarn.lock
16+
package-lock.json
17+
18+
# misc
19+
.DS_Store
20+
.env.local
21+
.env.development.local
22+
.env.test.local
23+
.env.production.local
24+
25+
npm-debug.log*
26+
yarn-debug.log*
27+
yarn-error.log*
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Example
2+
3+
To run this example:
4+
5+
- `npm install`
6+
- `npm run dev`
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" type="image/svg+xml" href="/emblem-light.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<meta name="theme-color" content="#000000" />
8+
9+
<title>
10+
TanStack Form React Field Errors From Form Validators Example App
11+
</title>
12+
</head>
13+
<body>
14+
<noscript>You need to enable JavaScript to run this app.</noscript>
15+
<div id="root"></div>
16+
<script type="module" src="/src/index.tsx"></script>
17+
</body>
18+
</html>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@tanstack/field-errors-from-form-validators",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite --port=3001",
7+
"build": "vite build",
8+
"preview": "vite preview",
9+
"test:types": "tsc"
10+
},
11+
"dependencies": {
12+
"@tanstack/react-form": "^0.29.2",
13+
"react": "^18.3.1",
14+
"react-dom": "^18.3.1"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^18.3.3",
18+
"@types/react-dom": "^18.3.0",
19+
"@vitejs/plugin-react": "^4.3.1",
20+
"vite": "^5.4.2"
21+
},
22+
"browserslist": {
23+
"production": [
24+
">0.2%",
25+
"not dead",
26+
"not op_mini all"
27+
],
28+
"development": [
29+
"last 1 chrome version",
30+
"last 1 firefox version",
31+
"last 1 safari version"
32+
]
33+
}
34+
}
Lines changed: 13 additions & 0 deletions
Loading
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useForm } from '@tanstack/react-form'
2+
import * as React from 'react'
3+
import { createRoot } from 'react-dom/client'
4+
5+
async function sleep(ms: number) {
6+
return new Promise((resolve) => {
7+
setTimeout(resolve, ms)
8+
})
9+
}
10+
11+
async function verifyAgeOnServer(age: number) {
12+
await sleep(Math.floor(Math.random() * 1000))
13+
return age <= 13
14+
}
15+
16+
async function checkIfUsernameIsTaken(name: string) {
17+
await sleep(Math.floor(Math.random() * 500))
18+
const usernames = ['user-1', 'user-2', 'user-3']
19+
return !usernames.includes(name)
20+
}
21+
22+
export default function App() {
23+
const form = useForm({
24+
defaultValues: {
25+
username: '',
26+
age: 0,
27+
},
28+
validators: {
29+
onSubmitAsync: async ({ value }) => {
30+
const [isRightAge, isUsernameAvailable] = await Promise.all([
31+
// Verify the age on the server
32+
verifyAgeOnServer(value.age),
33+
// Verify the availability of the username on the server
34+
checkIfUsernameIsTaken(value.username),
35+
])
36+
37+
if (!isRightAge || !isUsernameAvailable) {
38+
return {
39+
// The `form` key is optional
40+
form: 'Invalid data',
41+
fields: {
42+
...(!isRightAge ? { age: 'Must be 13 or older to sign' } : {}),
43+
...(!isUsernameAvailable
44+
? { username: 'Username is taken' }
45+
: {}),
46+
},
47+
}
48+
}
49+
50+
return null
51+
},
52+
},
53+
})
54+
55+
return (
56+
<div>
57+
<h1>Field Errors From The Form's validators Example</h1>
58+
<form
59+
onSubmit={(e) => {
60+
e.preventDefault()
61+
e.stopPropagation()
62+
void form.handleSubmit()
63+
}}
64+
>
65+
<form.Field
66+
name="username"
67+
validators={{
68+
onSubmit: ({ value }) => (!value ? 'Required field' : null),
69+
}}
70+
children={(field) => (
71+
<div>
72+
<label htmlFor={field.name}>Username:</label>
73+
<input
74+
id={field.name}
75+
name={field.name}
76+
value={field.state.value}
77+
onChange={(e) => {
78+
field.handleChange(e.target.value)
79+
}}
80+
/>
81+
{field.state.meta.errors.length > 0 ? (
82+
<em role="alert">{field.state.meta.errors.join(', ')}</em>
83+
) : null}
84+
</div>
85+
)}
86+
/>
87+
88+
<form.Field
89+
name="age"
90+
validators={{
91+
onSubmit: ({ value }) => (!value ? 'Required field' : null),
92+
}}
93+
children={(field) => (
94+
<div>
95+
<label htmlFor={field.name}>Age:</label>
96+
<input
97+
id={field.name}
98+
name={field.name}
99+
value={field.state.value}
100+
type="number"
101+
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
102+
/>
103+
{field.state.meta.errors.length > 0 ? (
104+
<em role="alert">{field.state.meta.errors.join(', ')}</em>
105+
) : null}
106+
</div>
107+
)}
108+
/>
109+
<form.Subscribe
110+
selector={(state) => [state.errorMap]}
111+
children={([errorMap]) =>
112+
errorMap.onSubmit ? (
113+
<div>
114+
<em>There was an error on the form: {errorMap.onSubmit}</em>
115+
</div>
116+
) : null
117+
}
118+
/>
119+
<form.Subscribe
120+
selector={(state) => [state.canSubmit, state.isSubmitting]}
121+
children={([canSubmit, isSubmitting]) => (
122+
<button type="submit" disabled={!canSubmit}>
123+
{isSubmitting ? '...' : 'Submit'}
124+
</button>
125+
)}
126+
/>
127+
</form>
128+
</div>
129+
)
130+
}
131+
132+
const rootElement = document.getElementById('root')!
133+
134+
createRoot(rootElement).render(<App />)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"compilerOptions": {
3+
"jsx": "react",
4+
"noEmit": true,
5+
"strict": true,
6+
"esModuleInterop": true,
7+
"lib": ["DOM", "DOM.Iterable", "ES2020"]
8+
}
9+
}

0 commit comments

Comments
 (0)