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.
TanStack Start
project, following the steps in the TanStack Start Quickstart Guide@tanstack/react-form
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 locationimport { 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 pathimport { 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 pathimport { 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.tsximport { 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 = form.useStore((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
.
Next.js
project, following the steps in the Next.js Documentation. Ensure you select yes
for Would you like to use App Router?
during the setup to access all new features provided by Next.js.@tanstack/react-form
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 clientimport { 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 clientimport { 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 = form.useStore((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:
typescript
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
Remix
project, following the steps in the Remix Documentation.@tanstack/react-form
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.tsximport { 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!
}
Finally, the action
will be called when the form submits.
// routes/_index/route.tsximport { 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 = form.useStore((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.