Skip to content

Building interactive forms with TanStack Form

Building interactive forms with TanStack Form

TanStack Form is a new headless form library that makes building more complex and interactive forms easy. TanStack has understood the power of ‘headless’ UI libraries for quite some time with some really incredible tools like TanStack Table and TanStack Query. Given the high quality of all the other libraries in the TanStack suite, I was excited to give this new form library a try.

If you’ve used react-hook-form before the two libraries have quite a bit in common. The transition should be mostly straightforward. Let's start by comparing the two libraries and bit and then dig into the TanStack Form API with some examples and code.

Comparison to react-hook-form

At a high level, the two libraries are pretty similar:

  • Headless, hook-based API
  • Performance (both libraries minimize amount of renders triggered by state changes)
  • Lightweight, zero-dependencies (TanStack 4.4kb, react-hook-form 9.7kb)
  • Comprehensive feature set
  • Type-safety / TypeScript support
  • Form / Input validation and schemas

There are a few things that set TanStack Form apart:

  • UI Agnostic - TanStack Form offers support for other UI libraries and frameworks through a plugin-style system. Currently, React & Solid is supported out of the box.
  • Simpler API and documentation - The API surface area is a bit smaller, and the documentation makes it easy to find the details on all the APIs.
  • First-class TypeScript support - TanStack Form provides really impressive type inference. It stays out of your way and lets you focus on writing code instead of wrangling types.

Building forms

Since the API for TanStack Form is pretty small, we can walk through the important APIs pretty quickly to see how they work together to help us build interactive client-side forms.

useForm(options)

useForm accepts an options object that accepts defaultValues, defaultState, and event handler functions like onSubmit, onChange, etc. The types are clear, and the source code is easy to read so you can refer to FormApi.ts file for more specifics. Currently, the examples on the website don’t provide a type for the generic TData that useForm accepts. It will infer the form types based on the defaultValues that are provided, but in this example, I will provide the type explicitly.

import { useForm } from "@tanstack/react-form";

interface CreateAccountFormData {
	email: string;
	password: string;
	displayName: string;
}

function CreateAccountForm() {
	const form = useForm<CreateAccountFormData>({
		defaultValues: {
			email: '',
			password: '',
			displayName: '',
		},
		onSubmit: (values) => {
			// values are typed for us
			// values.[email, password, displayName]
		}
	});
}

The API for your form is returned from the useForm hook. We will use this API to build the fields and their functionality in our component.

useField(opts)

If you want to abstract one of your form fields into its own component we can use the useField hook. The useField generic parameters take the type definition of your form and the field name.

function PasswordField() {
  const field = useField<CreateAccountFormData, "password">({
    name: "password",
    onChange: (value) =>
      value.length > 8 ? undefined : "Password must be 8 characters long",
  });
  const error = field.state.meta.errors[0];
  return (
    <>
      <label htmlFor={field.name}>Password</label>
      <input
        type="password"
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      {error && <span>{error}</span>}
    </>
  );
}

Validation is as simple as returning a string from your field onChange handler. For our password field we return an error message if the length isn’t at least 8 characters long. We bind our field states and event handlers to the form input by passing them in as props.

Component API

form includes a component API with a Context Provider, a component for rendering Field components, and a Subscribe component for subscribing to state changes. I’ve added the remaining fields to our form using the Field component and included our PasswordField from above. Using the Field component is similar to using the useField hook - the difference is our field instance gets passed in via a render prop.

return (
    <form.Provider>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          e.stopPropagation();
          void form.handleSubmit();
        }}
      >
        <div>
          <form.Field name="email">
            {(field) => (
              <>
                <label htmlFor={field.name}>Email</label>
                <input
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
	      required
                />
              </>
            )}
          </form.Field>
        </div>
        <div>
	<PasswordField />
        </div>
        <div>
          <form.Field name="displayName">
            {(field) => (
              <>
                <label htmlFor={field.name}>Display Name</label>
                <input
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
	      required
                />
              </>
            )}
          </form.Field>
        </div>
        <form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
              			{isSubmitting ? "..." : "Submit"}
            </button>
          )}
        </form.Subscribe>
      </form>
    </form.Provider>
  );

We wrapped our submit button with the Subscribe component which has a pretty interesting API. Subscribe allows you to subscribe to state changes granularly. Whatever state items you return from the selector gets passed into the render prop and re-render it on changes. This will be really useful in places where render performance is critical.

Async event handlers

We can use the built-in async handlers and debounce options to implement a search input. This feature makes a common pattern like this trivial.

function SearchField() {
  const field = useField<{ query: string }>, "query">({
    name: "query",
    onChangeAsync: (value) => searchFn(value),
    onChangeAsyncDebounceMs: 500
  });
  return (
      <input
        type="text"
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
  );
}

Async event handlers can be used for other things like async validation as well. We could update our email input from our create account form to check to see if the account already exists.

<form.Field 
  name="email" 
  onBlurAsync={async (value) => api.isEmailRegistered(value)}>
  {(field) => (
    <>
      <label htmlFor={field.name}>Email</label>
      <input
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        required
      />
    </>
  )}
</form.Field>

Validation adapters

Since this post was first written, TanStack Form has added adapters for popular schema validation libraries like Yup and Zod. The actual API for using the schemas is a little bit different then what you might be used to from react-hook-form. Instead of passing a schema definition for all of the fields to the root form API, you provide schemas for each field individually. We’ll look at the Zod example from the documentation.

import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@tanstack/zod-form-adapter'

export default function App() {
 const form = useForm({
   defaultValues: {
     firstName: '',
     lastName: '',
   },
   onSubmit: async ({ value }) => {
     // Do something with form data
     console.log(value)
   },
   // Add a validator to support Zod usage in Form and Field
   validatorAdapter: zodValidator,
 })

To use the schema validation you need to pass the schema validation adapter into the validatorAdapter option in the useForm declaration. Looking at the FieldInfo component from the example we can see that the internal state and API for form validation is the same. The adapter takes care of wiring up the schema validation library to handle the underlying field validation.

function FieldInfo({ field }: { field: FieldApi<any, any, any, any> }) {
 return (
   <>
     {field.state.meta.touchedErrors ? (
       <em>{field.state.meta.touchedErrors}</em>
     ) : null}
     {field.state.meta.isValidating ? 'Validating...' : null}
   </>
 )
}

So we can easily check the state of our field to see if there are any errors or if it’s processing some async validation logic.

We mentioned that without the schema adapter we can just return an error message string from a validation function to indicate an error. With the schema adapter, we instead just return the schema itself. This works for async validations as well.

<form.Field
    name="firstName"
    validators={{
        onChange: z
          .string()
           .min(3, 'First name must be at least 3 characters'),
         onChangeAsyncDebounceMs: 500,
         onChangeAsync: z.string().refine(
              async (value) => {
                   await new Promise((resolve) => setTimeout(resolve, 1000))
                   return !value.includes('error')
               },
               {
                 message: "No 'error' allowed in first name",
               },
           ),
     }}
/>

Initially, I wasn’t sure that I would like defining a schema at the field level instead of providing a schema up front for the entire form. After sitting with it for a bit, I think it’s a better solution. The validations are decoupled from the value of the field and it allows a clean and simple API to decide when they run. In the example above validations against the schema will run on every change to the field value but we could easily change this to onBlur or any of the other available event handlers.

Conclusion

If you don’t mind taking a chance on a newer library from a well-established author in the ecosystem, Tanstack Form is a solid choice.

  • The APIs are easy to understand, and it has some nice async event handling features that are extremely useful.
  • It includes adapters for using schema validation libraries like Zod.
  • It is UI library/framework agnostic (currently has plugins to support React, React Native, and Solid)
  • Quality TypeScript support In my opinion, it’s the most intuitive and flexible form library out there. TanStack and all of its libraries are amazingly well designed. 10/10

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co