Introducing TanStack Form

Adam Rackis Adam Rackis on

There’s no shortage of form libraries to help manage the complexity of form handling, particularly in React. In this post, we’ll look at TanStack Form. Like other TanStack libraries, Form takes strong typing and performance seriously. It’s also detail-oriented and has planned for every imaginable edge case.

Complexity?

Forms are a notoriously annoying part of React. They seem simple at first: just create some basic state for each input, wire up your controlled inputs, and that’s that. But of course you’ll need validation. And you’ll probably want to add some niceties, like clearing validation errors as a user types into an invalid field. And you’ll probably not want to dump your entire form into one component, so you’d just pass around all those state values. Or put them into context. Or you could use uncontrolled form inputs, in which case you don’t need those state values, but now you’ll be dealing with raw DOM elements for all your inputs.

Manually managing your own forms always starts simple, but quickly becomes a pain. Let’s look at how to manage it all with TanStack Form.

Our First Form

Let’s jump in. We’ll build a form to manage a Product of this structure:

export interface Product {
  name: string;
  price: number | string;
  added?: Date;
  description: string;
  skuNumber: string;
  metadata: { name: string; value: string }[];
}

const defaultProduct: Product = {
  name: "",
  price: 0,
  added: undefined,
  description: "",
  skuNumber: "",
  metadata: [],
};Code language: TypeScript (typescript)

TanStack Form gives us a useForm hook for generating our …form.

const form = useForm({
  defaultValues: defaultProduct,

  onSubmit: async ({ value }) => {
    // ...
  },
});Code language: TypeScript (typescript)

Now we can render our form.

<form
  onSubmit={event => {
    event.preventDefault();
    event.stopPropagation();

    form.handleSubmit();
  }}
></form>Code language: HTML, XML (xml)

The <form> rendered above is the form variable we just created from the useForm hook call, not a generic HTML <form>.

Our onSubmit handler prevents the native HTML form behavior, and then calls form.handleSubmit() which invokes any validation you define, which we’ll get to, and, if no validation errors are found, invokes the original onSubmit callback you passed to the useForm hook.

Managing Fields

Let’s look at a single field defined inside our form. We’ll look at the entire Field, and then pick it apart.

<form.Field
  name="name"
  validators={{
    onSubmit: ({ value }) => {
      if (!value) {
        return "Name is required";
      }
    },
  }}
  children={field => (
    <div>
      <Label htmlFor={field.name}>Product Name</Label>
      <Input
        id={field.name}
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={event => field.handleChange(event.target.value)}
      />
      {!field.state.meta.isValid && <p className="valid-text">{field.state.meta.errors.join(", ")}</p>}
      {field.state.meta.isPristine && <p className="pristine-text">Pristine</p>}
      {field.state.meta.isTouched && <p className="touched-text">Touched</p>}
      {field.state.meta.isDirty && <p className="dirty-text">Dirty</p>}
    </div>
  )}
/>Code language: HTML, XML (xml)

Let’s start at the very top. We have to specify which piece of data our form field is managing, and that’s what the name prop is for.

name = "name";Code language: TypeScript (typescript)

If you’re used to TanStack libraries, you’re probably used to incredibly meticulous static typing, and Form is no different.

When we defined our product:

const defaultProduct: Product = {
  name: "",
  price: 0,
  added: undefined,
  description: "",
  skuNumber: "",
  metadata: [],
};

// and then ...

useForm({
  defaultValues: defaultProduct,
  // ...
});Code language: TypeScript (typescript)

The structure of the defaultValues we provided became the structure of the data our form now collects, and maintains. This means things like our Field‘s name prop is statically checked, and therefore even autocompleted.

Field name autocomplete

Similarly, the value associated with any particular form field is also strongly typed, based on those same defaultValues.

Validators

Moving on to validators, have a look at this part:

validators={{
  onSubmit: ({ value }) => {
    if (!value) {
      return "Name is required";
    }
  },
}}Code language: TypeScript (typescript)

This defines our validation. TanStack Form allows you to specify where validation occurs. I like having these errors show up only after the user tries to submit the form, but you can specify onChange, onBlur, or even some other more advanced options. See the docs for more info.

Rendering the Actual Form Input

How do we actually render the form input? TanStack Form is headless; it gives you the state you need, allowing you to render whatever you want. It does this with a classic React pattern that’s not used quite as often anymore (hooks removed many of its applications), but is no less valuable for use cases exactly like this: render functions.

Some may not know this, but the children value passed into a React component does not have to be a React Node: you can also pass a function that returns your React node. That’s what this is:

children={(field) => (
  <div>
    <Label htmlFor={field.name}>Product Name</Label>
    <Input
      id={field.name}
      name={field.name}
      value={field.state.value}
      onBlur={field.handleBlur}
      onChange={(event) => field.handleChange(event.target.value)}
    />
    {!field.state.meta.isValid && <p className="valid-text">{field.state.meta.errors.join(", ")}</p>}
    {field.state.meta.isPristine && <p className="pristine-text">Pristine</p>}
    {field.state.meta.isTouched && <p className="touched-text">Touched</p>}
    {field.state.meta.isDirty && <p className="dirty-text">Dirty</p>}
  </div>
)}Code language: TypeScript (typescript)

You don’t have to use the children prop; you can also pass this function as the actual value in between <form.Field> and </form.Field>. The two are equivalent. The TanStack Form docs use the children prop, but you can use whichever you prefer; they’re identical.

TanStack Form’s Field component handles the grunt work of calling the function you provide, and it passes this function a parameter that has everything we need to render everything.

In this code, I’m rendering a ShadCN Label, and Input. The field prop passed to my render function gives me a name value, plus a state object that has things like the current value. Naturally, there’s an onChange handler we need to invoke with any updated values, but you might wonder why I need to pass an onBlur handler. That’s to help some of the field’s state. In the code above, you can see the validation error info attached to the field’s state.meta object, but there’s also input state like isTouched and isDirty. Check the the docs for a full accounting of all these various state values, but isTouched indicates whether the user has ever focused-and-blurred your input, and the onBlur callback is what makes this work.

Array Fields

Our original data had a metadata field that was an Array.

export interface Product {
  // ...
  metadata: { name: string; value: string }[];
}Code language: TypeScript (typescript)

Let’s see how TanStack Form manages that. First, we use a Field as we have been, but we set its mode to “array.” The “field” in the render prop will have a pushValue method for adding an item to the array, as well as a removeValue method for removing one of the items by index.

From there, field.state.value inside the Field component’s render function would be the array itself. We can loop it, and for each item, render another field for each item.

Let’s look at the code.

<form.Field name="metadata" mode="array">
  {field => (
    <div>
      <Button variant="outline" type="button" onClick={() => field.pushValue({ name: "", value: "" })}>
        Add Metadata
      </Button>
      {field.state.value.map((_, idx) => {
        return (
          <div key={idx}>
            <div>
              <form.Field
                name={`metadata[${idx}].name`}
                validators={{
                  onSubmit: ({ value }) => {
                    if (!value) {
                      return "Name is required";
                    }
                  },
                }}
                children={field => (
                  <div>
                    <Label htmlFor={field.name}>Name</Label>
                    <Input
                      id={field.name}
                      name={field.name}
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={event => field.handleChange(event.target.value)}
                      placeholder=""
                    />
                    {!field.state.meta.isValid && <p class="text-error">{field.state.meta.errors.join(", ")}</p>}
                  </div>
                )}
              />
            </div>
            <div>
              <form.Field
                name={`metadata[${idx}].value`}
                validators={{
                  onSubmit: ({ value }) => {
                    if (!value) {
                      return "Value is required";
                    }
                  },
                }}
                children={field => (
                  <div>
                    <Label htmlFor={field.name}>Value</Label>
                    <Input
                      id={field.name}
                      name={field.name}
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={event => field.handleChange(event.target.value)}
                      placeholder=""
                    />
                    {!field.state.meta.isValid && <p className="text-error">{field.state.meta.errors.join(", ")}</p>}
                  </div>
                )}
              />
            </div>
            <div>
              <Button type="button" onClick={() => field.removeValue(idx)}>
                Remove
              </Button>
            </div>
          </div>
        );
      })}
    </div>
  )}
</form.Field>
Code language: Vala (vala)

Notice the name on the inner field.

name={`metadata[${idx}].name`}Code language: TypeScript (typescript)

TanStack Form allows, and even type checks, that this is a perfectly valid name.

We can add items to our metadata.

<Button
  type="button"
  onClick={() => field.pushValue({ name: "", value: "" })}>
    Add Metadata
</Button>Code language: TypeScript (typescript)

As well as remove them.

<Button
  type="button"
  onClick={() => field.removeValue(idx)}>
  Remove
</Button>Code language: TypeScript (typescript)

Referencing Other Field Values

Let’s get a little contrived and pretend that, when entering a product, if the price is > 50, we require a description. Let’s further pretend that whenever price has a value > 50, we immediately want to display a helpful message indicating that a description will be required since the price is what it is.

The naive solution won’t work; we can’t just do this:

const DescriptionFieldUseStore: FC<{ form: ProductForm }> = (props) => {
  const { form } = props;

  const price = form.getFieldValue("price");
  const descriptionRequired = typeof price === "number" && price > 50;

  // later ...
  {descriptionRequired && <p className="text-yellow-800">Description is required when price is greater than $50</p>}
}Code language: TypeScript (typescript)

The reason is that form.getFieldValue("price"); is not reactive. This is for performance reasons. If you want to dynamically and reactively get access to other parts of the form, you have a few options.

useStore

The useStore hook is one option.

import { useStore } from "@tanstack/react-form";Code language: TypeScript (typescript)

This allows you to grab whatever you need reactively.

const price = useStore(form.store, state => state.values.price);Code language: TypeScript (typescript)

Subscribe

The other option is the Subscribe component. You specify the slice of the form’s state you want, and you’re given a render function with that reactive slice of the form passed in

<form.Subscribe selector={(formState) => ({ price: formState.values.price })}>
  {({ price }) => {
    const descriptionRequired = typeof price === "number" && price > 50;
    return (
      <form.Field
        name="description"
        // and so on...Code language: TypeScript (typescript)

Use whichever is more convenient for your particular use case.

Composition

Do we have everything we need? Not really. Our form object was created from the useForm hook, and we’ve been using that for our Field components. Field is not a component we import; instead, it’s created on the fly, from the useForm hook, and attached to the form object returned therefrom. The reason is that all our form fields will be strongly typed, with appropriate names, values, etc.

But we may not want to put our entire form into one big React component if things grow even moderately large. Breaking up our form into smaller components is a great idea, and we could simply pass our form object around as needed, as a prop.

But what’s the type of this form object? Unfortunately, Typescript reports it as:

const form: ReactFormExtendedApi<Product, FormValidateOrFn<Product> | undefined, FormValidateOrFn<Product> | undefined, FormAsyncValidateOrFn<Product> | undefined, FormValidateOrFn<Product> | undefined, FormAsyncValidateOrFn<Product> | undefined, FormValidateOrFn<Product> | undefined, FormAsyncValidateOrFn<Product> | undefined, FormValidateOrFn<...> | undefined, FormAsyncValidateOrFn<...> | undefined, FormAsyncValidateOrFn<...> | undefined, unknown>Code language: JavaScript (javascript)

The return type from the useForm type is a generic that takes a lot of args, and they’re required. These control things like the data in the form, obviously, but also things like validation.

Fortunately, a good understanding of TypeScript can go a long, long way here. Let’s move the call to useForm into its own function

export const useProductForm = (onSubmit: (value: Product) => void) => {
  return useForm({
    defaultValues: defaultProduct,

    onSubmit: async ({ value }) => {
      onSubmit(value);
    },
  });
};Code language: TypeScript (typescript)

Now we can leverage some TypeScript helpers and inferred typing to easily get the type we’re looking for.

export type ProductForm = ReturnType<typeof useProductForm>;Code language: TypeScript (typescript)

And now we can break up our form into smaller components, and pass the form object in correctly.

const DescriptionFieldSubscribe: FC<{ form: ProductForm }> = (props) => {Code language: TypeScript (typescript)

Composing Better

Let’s imagine this bit of markup.

<div>
  <Label htmlFor={field.name}>Product Name</Label>
  <Input
    id={field.name}
    name={field.name}
    value={field.state.value}
    onBlur={field.handleBlur}
    onChange={event => field.handleChange(event.target.value)}
  />
  {!field.state.meta.isValid && <p className="text-error">{field.state.meta.errors.join(", ")}</p>}
</div>Code language: HTML, XML (xml)

Maybe it’s even more complex than that, and it’s clear that it would make sense to put into a reusable component.

You have a few options.

The AnyFieldApi Type

There’s a nice AnyFieldApi type exported from TanStack Form. This faithfully represents any field object. The only catch is that the value is typed as any. How could it not? It’s an umbrella type for any field. But in practice, this might be fine.

But you can define any components you want, and pass your field in as AnyFieldApi, and then just type the value prop as needed.

const SimpleTextField: FC<{ label: string; field: AnyFieldApi }> = props => {
  const { label, field } = props;

  return (
    <div>
      <Label htmlFor={field.name}>{label}</Label>
      <Input
        id={field.name}
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={event => field.handleChange(event.target.value)}
      />
      {!field.state.meta.isValid && <p className="text-error">{field.state.meta.errors.join(", ")}</p>}
    </div>
  );
};Code language: TypeScript (typescript)

Then:

<form.Field
  name="skuNumber"
  validators={{
    onSubmit: ({ value }) => {
      if (!value) {
        return "SKU is required";
      }
    },
  }}
  children={field => <SimpleTextField label="SKU Number" field={field} />}
/>Code language: HTML, XML (xml)

FieldComponents and useFieldContext

Really, we could end this post here. Everything we’ve seen will cover the overwhelming majority of any use case imaginable. But Form has some advanced features that are at least worth looking at.

Let’s start with some new imports.

import { createFormHook, createFormHookContexts } from "@tanstack/react-form";Code language: TypeScript (typescript)

This part is a little weird and won’t make complete sense just yet, but we’ll clear it up as we go.

const { fieldContext, useFieldContext, formContext } = createFormHookContexts();Code language: TypeScript (typescript)

Let’s now create a reusable form component.

const BasicTextField: FC<{ label: string }> = (props) => {
  const { label } = props;
  const field = useFieldContext<string>();

  return (
    <div>
      <Label htmlFor={field.name}>{label}</Label>
      <Input
        id={field.name}
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(event) => field.handleChange(event.target.value)}
      />
      {!field.state.meta.isValid && <p className="text-error">{field.state.meta.errors.join(", ")}</p>}
    </div>
  );
};Code language: TypeScript (typescript)

It’s just a simple component, which takes a label as a prop. But notice there’s no field prop; instead, we have this:

const field = useFieldContext<string>();Code language: TypeScript (typescript)

This says, “grab whatever the current field is, in this form.” And since we can’t rely on inferred typing, since we don’t have direct access to the type, we have to pass a generic argument to let TypeScript know that this is, in fact, a string field.

Now we can tell TanStack about our custom form component and get back a new hook to create our form with.

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { BasicTextField },
  formComponents: {},
});

export const useProductForm = (onSubmit: (value: Product) => void) => {
  return useAppForm({
    defaultValues: defaultProduct,

    onSubmit: async ({ value }) => {
      onSubmit(value);
    },
  });
};Code language: TypeScript (typescript)

Now we can do everything as before, but when we provide the markup for a field, we have a new option.

<form.AppField
  name="name"
  validators={{
    onSubmit: ({ value }) => {
      if (!value) {
        return "Product name is required!";
      }
    },
  }}
  children={(field) => <field.BasicTextField label="Product Name" />}
/>Code language: HTML, XML (xml)

This allows us to attach any custom components directly to our form, which can then access whatever field you’re currently editing.

Form also supports reusing groups of components at the form level. For example, if you had a call to <form.Subscribe> and wanted to reuse that entire structure, there are utilities for that (formComponents). It’s a variation on the theme we already saw, so check the docs if you’re curious.

For extremely large applications, these features can come in handy and help keep everything organized.

Concluding Thoughts

TanStack Form is a surprisingly pleasant form library. The API is a bit more superficially complex than you might expect, but once you understand how it works, you immediately see its power, and flexibility.

Wanna learn React deeply?

Leave a Reply

Your email address will not be published. Required fields are marked *

$966,000

Frontend Masters donates to open source projects through thanks.dev and Open Collective, as well as donates to non-profits like The Last Mile, Annie Canons, and Vets Who Code.