React Meta-Framework Usage

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:

Using TanStack Form in TanStack Start

This section focuses on integrating TanStack Form with TanStack Start.

TanStack Start Prerequisites

Start integration

Let's start by creating a formOption that we'll use to share the form's shape across the client and server.

typescript
// 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.

typescript
// 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:

typescript
// 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.

tsx
// 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 = 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>
  )
}
// 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 = 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>
  )
}

Using TanStack Form in a Next.js App Router

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 Prerequisites

  • Start a new 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.
  • Install @tanstack/react-form
  • Install any form validator of your choice. [Optional]

App Router integration

Let's start by creating a formOption that we'll use to share the form's shape across the client and server.

typescript
// 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.

typescript
// 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.

tsx
// 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>
  )
}
// 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.
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.

Using TanStack Form in Remix

Before reading this section, it's suggested you understand how Remix actions work. Check out Remix's docs for more information

Remix Prerequisites

Remix integration

Let's start by creating a formOption that we'll use to share the form's shape across the client and server.

typescript
// 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.

tsx
// 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.

tsx
// 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 = 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>
  )
}
// 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 = 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.

Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.