TanStack Form is compatible with React out of the box, supporting SSR and being framework-agnostic. However, specific configurations are necessary, according to your chosen framework.
Today we support the following meta-frameworks:
This section focuses on integrating TanStack Form with TanStack Start.
Let's start by creating a formOption that we'll use to share the form's shape across the client and server.
// app/routes/index.tsx, but can be extracted to any other path
// Notice the import path is different from the typical import location
import { formOptions } from '@tanstack/react-form/start'
// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
// app/routes/index.tsx, but can be extracted to any other path
// Notice the import path is different from the typical import location
import { formOptions } from '@tanstack/react-form/start'
// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
Next, we can create a Start Server Action that will handle the form submission on the server.
// app/routes/index.tsx, but can be extracted to any other path
import {
createServerValidate,
ServerValidateError,
} from '@tanstack/react-form/start'
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export const handleForm = createServerFn(
'POST',
async (formData: FormData, ctx) => {
try {
await serverValidate(ctx, formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.response
}
// Some other error occurred when parsing the form
console.error(e)
return new Response('There was an internal error', {
status: 500,
})
}
return new Response('Form has validated successfully', {
status: 200,
})
},
)
// app/routes/index.tsx, but can be extracted to any other path
import {
createServerValidate,
ServerValidateError,
} from '@tanstack/react-form/start'
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export const handleForm = createServerFn(
'POST',
async (formData: FormData, ctx) => {
try {
await serverValidate(ctx, formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.response
}
// Some other error occurred when parsing the form
console.error(e)
return new Response('There was an internal error', {
status: 500,
})
}
return new Response('Form has validated successfully', {
status: 200,
})
},
)
Then we need to establish a way to grab the form data from serverValidate's response using another server action:
// app/routes/index.tsx, but can be extracted to any other path
import { getFormData } from '@tanstack/react-form/start'
export const getFormDataFromServer = createServerFn('GET', async (_, ctx) => {
return getFormData(ctx)
})
// app/routes/index.tsx, but can be extracted to any other path
import { getFormData } from '@tanstack/react-form/start'
export const getFormDataFromServer = createServerFn('GET', async (_, ctx) => {
return getFormData(ctx)
})
Finally, we'll use getFormDataFromServer in our loader to get the state from our server into our client and handleForm in our client-side form component.
// app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => ({
state: await getFormDataFromServer(),
}),
})
function Home() {
const { state } = Route.useLoaderData()
const form = useForm({
...formOpts,
transform: useTransform((baseForm) => mergeForm(baseForm, state), [state]),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<form action={handleForm.url} method="post" encType={'multipart/form-data'}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}
// app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => ({
state: await getFormDataFromServer(),
}),
})
function Home() {
const { state } = Route.useLoaderData()
const form = useForm({
...formOpts,
transform: useTransform((baseForm) => mergeForm(baseForm, state), [state]),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<form action={handleForm.url} method="post" encType={'multipart/form-data'}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}
Before reading this section, it's suggested you understand how React Server Components and React Server Actions work. Check out this blog series for more information
This section focuses on integrating TanStack Form with Next.js, particularly using the App Router and Server Actions.
Let's start by creating a formOption that we'll use to share the form's shape across the client and server.
// shared-code.ts
// Notice the import path is different from the client
import { formOptions } from '@tanstack/react-form/nextjs'
// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
// shared-code.ts
// Notice the import path is different from the client
import { formOptions } from '@tanstack/react-form/nextjs'
// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
Next, we can create a React Server Action that will handle the form submission on the server.
// action.ts
'use server'
// Notice the import path is different from the client
import {
ServerValidateError,
createServerValidate,
} from '@tanstack/react-form/nextjs'
import { formOpts } from './shared-code'
// Create the server action that will infer the types of the form from `formOpts`
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export default async function someAction(prev: unknown, formData: FormData) {
try {
await serverValidate(formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}
// Some other error occurred while validating your form
throw e
}
// Your form has successfully validated!
}
// action.ts
'use server'
// Notice the import path is different from the client
import {
ServerValidateError,
createServerValidate,
} from '@tanstack/react-form/nextjs'
import { formOpts } from './shared-code'
// Create the server action that will infer the types of the form from `formOpts`
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export default async function someAction(prev: unknown, formData: FormData) {
try {
await serverValidate(formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}
// Some other error occurred while validating your form
throw e
}
// Your form has successfully validated!
}
Finally, we'll use someAction in our client-side form component.
// client-component.tsx
'use client'
import { useActionState } from 'react'
import { initialFormState } from '@tanstack/react-form/nextjs'
// Notice the import is from `react-form`, not `react-form/nextjs`
import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
import someAction from './action'
import { formOpts } from './shared-code'
export const ClientComp = () => {
const [state, action] = useActionState(someAction, initialFormState)
const form = useForm({
...formOpts,
transform: useTransform((baseForm) => mergeForm(baseForm, state!), [state]),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<form action={action as never} onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}
// client-component.tsx
'use client'
import { useActionState } from 'react'
import { initialFormState } from '@tanstack/react-form/nextjs'
// Notice the import is from `react-form`, not `react-form/nextjs`
import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
import someAction from './action'
import { formOpts } from './shared-code'
export const ClientComp = () => {
const [state, action] = useActionState(someAction, initialFormState)
const form = useForm({
...formOpts,
transform: useTransform((baseForm) => mergeForm(baseForm, state!), [state]),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<form action={action as never} onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}
Here, we're using React's useActionState hook and TanStack Form's useTransform hook to merge state returned from the server action with the form state.
If you get the following error in your Next.js application:
typescriptx You're importing a component that needs `useState`. This React hook only works in a client component. To fix, mark the file (or its parent) with the `"use client"` directive.
x You're importing a component that needs `useState`. This React hook only works in a client component. To fix, mark the file (or its parent) with the `"use client"` directive.
This is because you're not importing server-side code from @tanstack/react-form/nextjs. Ensure you're importing the correct module based on the environment.
This is a limitation of Next.js. Other meta-frameworks will likely not have this same problem.
Before reading this section, it's suggested you understand how Remix actions work. Check out Remix's docs for more information
Let's start by creating a formOption that we'll use to share the form's shape across the client and server.
// routes/_index/route.tsx
import { formOptions } from '@tanstack/react-form/remix'
// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
// routes/_index/route.tsx
import { formOptions } from '@tanstack/react-form/remix'
// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
Next, we can create an action that will handle the form submission on the server.
// routes/_index/route.tsx
import {
ServerValidateError,
createServerValidate,
formOptions
} from '@tanstack/react-form/remix'
import type { ActionFunctionArgs } from '@remix-run/node'
// export const formOpts = formOptions({
// Create the server action that will infer the types of the form from `formOpts`
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export async function action({request}: ActionFunctionArgs) {
const formData = await request.formData()
try {
await serverValidate(formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}
// Some other error occurred while validating your form
throw e
}
// Your form has successfully validated!
}
// routes/_index/route.tsx
import {
ServerValidateError,
createServerValidate,
formOptions
} from '@tanstack/react-form/remix'
import type { ActionFunctionArgs } from '@remix-run/node'
// export const formOpts = formOptions({
// Create the server action that will infer the types of the form from `formOpts`
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export async function action({request}: ActionFunctionArgs) {
const formData = await request.formData()
try {
await serverValidate(formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}
// Some other error occurred while validating your form
throw e
}
// Your form has successfully validated!
}
Finally, the action will be called when the form submits.
// routes/_index/route.tsx
import { Form, useActionData } from '@remix-run/react'
import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
import {
ServerValidateError,
createServerValidate,
formOptions,
initialFormState,
} from '@tanstack/react-form/remix'
import type { ActionFunctionArgs } from '@remix-run/node'
// export const formOpts = formOptions({
// const serverValidate = createServerValidate({
// export async function action({request}: ActionFunctionArgs) {
export default function Index() {
const actionData = useActionData<typeof action>()
const form = useForm({
...formOpts,
transform: useTransform(
(baseForm) => mergeForm(baseForm, actionData ?? initialFormState),
[actionData],
),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<Form method="post" onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</Form>
)
}
// routes/_index/route.tsx
import { Form, useActionData } from '@remix-run/react'
import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
import {
ServerValidateError,
createServerValidate,
formOptions,
initialFormState,
} from '@tanstack/react-form/remix'
import type { ActionFunctionArgs } from '@remix-run/node'
// export const formOpts = formOptions({
// const serverValidate = createServerValidate({
// export async function action({request}: ActionFunctionArgs) {
export default function Index() {
const actionData = useActionData<typeof action>()
const form = useForm({
...formOpts,
transform: useTransform(
(baseForm) => mergeForm(baseForm, actionData ?? initialFormState),
[actionData],
),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<Form method="post" onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</Form>
)
}
Here, we're using Remix's useActionData hook and TanStack Form's useTransform hook to merge state returned from the server action with the form state.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.