Skip to content

Commit cc10c8b

Browse files
committed
chore: apply suggestions
1 parent 0932871 commit cc10c8b

1 file changed

Lines changed: 78 additions & 76 deletions

File tree

src/blog/announcing-tanstack-form-v2-alpha.md

Lines changed: 78 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: 'Form v2 is here: All you need to know about the alpha'
2+
title: "Form v2 is here: All you need to know about the alpha"
33
published: 2026-08-06
44
excerpt: The TanStack Form v2 alpha is here with more flexible validators, schema-oriented forms, safer form composition, and simpler SSR.
55
library: form
@@ -21,120 +21,120 @@ v2 uses a pipeline instead. Each validator gets its own entry and declares the e
2121

2222
### One validator, multiple triggers
2323

24-
#### Before (v1)
24+
### Before (v1)
2525

2626
Say you want to validate a field when its value changes and when it loses focus. v1 couldn't attach one validator to both events directly, so you had to add the same validator twice.
2727

2828
Because v1 registered the validator once for each event, it could produce duplicate errors. Your app then had to remove those duplicates before showing them in the UI.
2929

3030
```ts title="v1"
3131
const form = useForm({
32-
defaultValues: { name: '' },
32+
defaultValues: { name: "" },
3333
validators: {
3434
onChange: mySchema,
3535
onBlur: mySchema,
3636
},
37-
})
37+
});
3838
```
3939

40-
#### After (v2)
40+
### After (v2)
4141

4242
In v2, you define the validator once and list both events in `triggers`. It can run on change and blur without duplicating its setup or its errors.
4343

4444
```ts title="v2"
4545
const form = useForm({
46-
defaultValues: { name: '' },
46+
defaultValues: { name: "" },
4747
validators: [
4848
{
4949
run: mySchema,
50-
triggers: ['change', 'blur'],
50+
triggers: ["change", "blur"],
5151
},
5252
],
53-
})
53+
});
5454
```
5555

5656
### Multiple validators, one trigger
5757

58-
#### Before (v1)
58+
### Before (v1)
5959

6060
The opposite was awkward too. Say you want to run both a schema validator and a reserved-username check whenever a field changes. v1 only had one `onChange` key, so you had to wrap both validators in a single callback and control how they ran yourself:
6161

6262
```ts title="v1"
6363
const form = useForm({
64-
defaultValues: { name: '' },
64+
defaultValues: { name: "" },
6565
validators: {
6666
onChange: ({ formApi, value }) => {
67-
const errors = formApi.parseValuesWithSchema(mySchema)
67+
const errors = formApi.parseValuesWithSchema(mySchema);
6868

6969
// Stop if the schema validator found any errors.
70-
if (errors) return errors
70+
if (errors) return errors;
7171

72-
return checkReservedUsername(value)
72+
return checkReservedUsername(value);
7373
},
7474
},
75-
})
75+
});
7676
```
7777

78-
#### After (v2)
78+
### After (v2)
7979

8080
In v2, the two validators stay separate even though they share a trigger. Setting `bailIfInvalid` on the username check makes it run only if the schema validator passes, just like the early return in the v1 example.
8181

8282
```ts title="v2"
8383
const form = useForm({
84-
defaultValues: { name: '' },
84+
defaultValues: { name: "" },
8585
validators: [
8686
{
8787
run: mySchema,
88-
triggers: ['change'],
88+
triggers: ["change"],
8989
},
9090
{
9191
run: ({ value }) => checkReservedUsername(value),
92-
triggers: ['change'],
92+
triggers: ["change"],
9393
// Only run this check if the schema validator passes.
9494
bailIfInvalid: true,
9595
},
9696
],
97-
})
97+
});
9898
```
9999

100100
### Conditional validators
101101

102102
Sometimes you only want an event to trigger validation after something else has happened. One common React Hook Form pattern is to validate on submit first, then validate on every change after the first submission attempt.
103103

104-
#### Before (v1)
104+
### Before (v1)
105105

106106
In v1, general conditions had to live inside the validator. The function still ran on every change, only to return early while the condition was false. For this particular submit-then-change pattern, v1 also offered `onDynamic` together with `revalidateLogic()`:
107107

108108
```ts title="v1"
109109
const form = useForm({
110-
defaultValues: { name: '' },
110+
defaultValues: { name: "" },
111111
validationLogic: revalidateLogic(),
112112
validators: {
113113
onDynamic: mySchema,
114114
},
115-
})
115+
});
116116
```
117117

118-
#### After (v2)
118+
### After (v2)
119119

120120
In v2, each trigger can include a `when` condition. The validator still runs on submit, but the change trigger only becomes active after the first submission attempt. Until then, changes don't call the validator at all. The condition now sits next to the trigger it controls, with no early return inside the validator or separate validation setting.
121121

122122
```ts title="v2"
123123
const form = useForm({
124-
defaultValues: { name: '' },
124+
defaultValues: { name: "" },
125125
validators: [
126126
{
127127
run: schema,
128128
triggers: [
129129
{
130-
trigger: 'change',
130+
trigger: "change",
131131
// After the first submission attempt, validate every change.
132132
when: ({ formApi }) => formApi.state.submissionAttempts > 0,
133133
},
134134
],
135135
},
136136
],
137-
})
137+
});
138138
```
139139

140140
## Listeners rework
@@ -152,7 +152,7 @@ Consider an appointment form. Its schema requires a date, but we don't want to p
152152
```ts
153153
const schema = z.object({
154154
appointment: z.date(),
155-
})
155+
});
156156

157157
/*
158158
z.input<typeof schema> = {
@@ -176,10 +176,10 @@ const formOpts = formOptions({
176176
{
177177
// Error: The form is `appointment: null`, but the schema expects `Date`.
178178
run: schema,
179-
triggers: ['change'],
179+
triggers: ["change"],
180180
},
181181
],
182-
})
182+
});
183183
```
184184

185185
```ts title="Strict schema"
@@ -191,10 +191,10 @@ const formOpts = formOptions.strictSchema({
191191
validators: [
192192
{
193193
run: schema,
194-
triggers: ['change'],
194+
triggers: ["change"],
195195
},
196196
],
197-
})
197+
});
198198
```
199199

200200
```ts title="Loose schema"
@@ -206,17 +206,17 @@ const formOpts = formOptions.looseSchema({
206206
validators: [
207207
{
208208
run: schema,
209-
triggers: ['change'],
209+
triggers: ["change"],
210210
},
211211
],
212-
})
212+
});
213213
```
214214

215215
<!-- ::end:tabs -->
216216

217217
## Form Composition type safety
218218

219-
#### Before (v1)
219+
### Before (v1)
220220

221221
Form composition made it possible to bundle reusable components with a field and reduced the boilerplate needed to build forms. In v1, however, those components weren't restricted by the field's value type. A string field such as `email` could render a `NumberInput` without any warning about the mismatch:
222222

@@ -233,7 +233,7 @@ Form composition made it possible to bundle reusable components with a field and
233233
</form.AppField>
234234
```
235235

236-
#### After (v2)
236+
### After (v2)
237237

238238
v2 lets composed field components be branded with the value types they support. Once `email` is inferred as a string field, incompatible components are left out of its field API. Trying to access `field.NumberInput` therefore produces a type error before the form reaches the browser.
239239

@@ -263,69 +263,70 @@ v1 configured server validation separately from the shared form options. Validat
263263
<!-- ::start:tabs variant="files" -->
264264

265265
```ts title="shared-code.ts"
266-
import { formOptions } from '@tanstack/react-form-nextjs'
266+
import { formOptions } from "@tanstack/react-form-nextjs";
267267

268268
export const formOpts = formOptions({
269269
defaultValues: { age: 0 },
270-
})
270+
});
271271
```
272272

273273
```ts title="action.ts"
274-
'use server'
274+
"use server";
275275

276276
import {
277277
createServerValidate,
278278
ServerValidateError,
279-
} from '@tanstack/react-form-nextjs'
280-
import { formOpts } from './shared-code'
279+
} from "@tanstack/react-form-nextjs";
280+
import { z } from "zod";
281+
import { formOpts } from "./shared-code";
281282

282283
const mySchema = z.object({
283-
age: z.coerce.number().min(13, 'You must be 13 at least 13'),
284-
})
284+
age: z.coerce.number().min(13, "You must be at least 13"),
285+
});
285286

286287
const serverValidate = createServerValidate({
287288
...formOpts,
288289
onServerValidate: mySchema,
289-
})
290+
});
290291

291292
export async function submit(_previous: unknown, formData: FormData) {
292293
try {
293-
const values = await serverValidate(formData)
294+
const values = await serverValidate(formData);
294295

295296
// Use values...
296297
} catch (error) {
297298
// The returned form state loses its inferred type after this check.
298299
if (error instanceof ServerValidateError) {
299-
return error.formState
300+
return error.formState;
300301
}
301302

302-
throw error
303+
throw error;
303304
}
304305
}
305306
```
306307

307308
```tsx title="client.tsx"
308-
'use client'
309+
"use client";
309310

310-
import { useActionState } from 'react'
311+
import { useActionState } from "react";
311312
import {
312313
initialFormState,
313314
mergeForm,
314315
useForm,
315316
useTransform,
316-
} from '@tanstack/react-form-nextjs'
317-
import { submit } from './action'
318-
import { formOpts } from './shared-code'
317+
} from "@tanstack/react-form-nextjs";
318+
import { submit } from "./action";
319+
import { formOpts } from "./shared-code";
319320

320321
export function Form() {
321-
const [state, action] = useActionState(submit, initialFormState)
322+
const [state, action] = useActionState(submit, initialFormState);
322323

323324
const form = useForm({
324325
...formOpts,
325326
transform: useTransform((baseForm) => mergeForm(baseForm, state!), [state]),
326-
})
327+
});
327328

328-
return <form action={action as never}>{/* form.Field components */}</form>
329+
return <form action={action as never}>{/* form.Field components */}</form>;
329330
}
330331
```
331332

@@ -338,68 +339,69 @@ v2 moves the server validator into the shared `formOpts`, so the same configurat
338339
<!-- ::start:tabs variant="files" -->
339340

340341
```ts title="shared-code.ts"
341-
import { formOptions } from '@tanstack/react-form'
342+
import { formOptions } from "@tanstack/react-form";
343+
import { z } from "zod";
342344

343345
const mySchema = z.object({
344-
age: z.coerce.number().min(13, 'You must be 13 at least 13'),
345-
})
346+
age: z.coerce.number().min(13, "You must be 13 at least 13"),
347+
});
346348

347349
export const formOpts = formOptions({
348350
defaultValues: { age: 0 },
349351
validators: [
350352
{
351-
triggers: ['server'],
353+
triggers: ["server"],
352354
runOnSubmit: false,
353355
run: mySchema,
354356
},
355357
],
356-
})
358+
});
357359
```
358360

359361
```ts title="action.ts"
360-
'use server'
362+
"use server";
361363

362364
import {
363365
initialServerFormState,
364366
serverValidateHelper,
365-
} from '@tanstack/react-form'
366-
import { next } from '@tanstack/react-form-nextjs'
367-
import { formOpts } from './shared-code'
367+
} from "@tanstack/react-form";
368+
import { next } from "@tanstack/react-form-nextjs";
369+
import { formOpts } from "./shared-code";
368370

369371
const { createServerValidate } = serverValidateHelper({
370372
framework: next(),
371-
})
373+
});
372374

373-
const serverValidate = createServerValidate(formOpts)
375+
const serverValidate = createServerValidate(formOpts);
374376

375377
export async function submit(_previous: unknown, formData: FormData) {
376-
const result = await serverValidate(formData)
378+
const result = await serverValidate(formData);
377379

378380
// serverState keeps the type inferred from formOpts.
379-
if (!result.success) return result.serverState
381+
if (!result.success) return result.serverState;
380382

381383
// Use result.values...
382-
return initialServerFormState
384+
return initialServerFormState;
383385
}
384386
```
385387

386388
```tsx title="client.tsx"
387-
'use client'
389+
"use client";
388390

389-
import { useActionState } from 'react'
390-
import { initialServerFormState, useForm } from '@tanstack/react-form'
391-
import { submit } from './action'
392-
import { formOpts } from './shared-code'
391+
import { useActionState } from "react";
392+
import { initialServerFormState, useForm } from "@tanstack/react-form";
393+
import { submit } from "./action";
394+
import { formOpts } from "./shared-code";
393395

394396
export function Form() {
395-
const [serverState, action] = useActionState(submit, initialServerFormState)
397+
const [serverState, action] = useActionState(submit, initialServerFormState);
396398

397399
const form = useForm({
398400
...formOpts,
399401
serverState,
400-
})
402+
});
401403

402-
return <form action={action}>{/* form.Field components */}</form>
404+
return <form action={action}>{/* form.Field components */}</form>;
403405
}
404406
```
405407

0 commit comments

Comments
 (0)