Introducing TanStack Form
Introducing TanStack Form êŽë š
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: [],
};
TanStack Form gives us a useForm hook for generating our âŠform.
const form = useForm({
defaultValues: defaultProduct,
onSubmit: async ({ value }) => {
// ...
},
});
Now we can render our form.
<form
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
}}
></form>
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>
)}
/>
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";
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,
// ...
});
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.

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";
}
},
}}
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:
<form.Field
...
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>
)}
>
{/* ... */}
</form.Field>
Note
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 }[];
}
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>
Notice the name on the inner field.
name={`metadata[${idx}].name`}`
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>
As well as remove them.
<Button
type="button"
onClick={() => field.removeValue(idx)}>
Remove
</Button>
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>}
}
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";
This allows you to grab whatever you need reactively.
const price = useStore(form.store, state => state.values.price);
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...
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>
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);
},
});
};
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>;
And now we can break up our form into smaller components, and pass the form object in correctly.
const DescriptionFieldSubscribe: FC<{ form: ProductForm }> = (props) => {
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>
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>
);
};
Then:
<form.Field
name="skuNumber"
validators={{
onSubmit: ({ value }) => {
if (!value) {
return "SKU is required";
}
},
}}
children={field => <SimpleTextField label="SKU Number" field={field} />}
/>
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";
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();
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>
);
};
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>();
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);
},
});
};
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" />}
/>
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.