In my post, "Life after Next.js: A New and Sunny Start," I talked about my journey migrating to TanStack Start and the freedom it brought. One of the loose ends I mentioned was the form situation. I'm a big fan of the aesthetics and developer experience of shadcn/ui, but its default form component is built on react-hook-form. As I'm going all-in on the TanStack ecosystem, I naturally wanted to use TanStack Form.
This presented a classic developer dilemma: do I stick with a component that doesn't quite fit my new stack, or do I build something better? The answer was obvious. I couldn't find a clean, existing solution that married the beauty of shadcn/ui with the power and type-safety of TanStack Form. So, I decided to build it myself.
Today, I'm excited to share the result: a component that seamlessly integrates shadcn/ui with TanStack Form, preserving the core principles of both libraries. It's type-safe, easy to use, and maintains that clean shadcn/ui look and feel.
You can check out the component's website and find the full source code on the GitHub repository.
Why Bother?
TanStack Form offers incredible power with its framework-agnostic, type-safe approach to form state management. shadcn/ui, on the other hand, provides beautiful, accessible, and unopinionated components. The goal was to get the best of both worlds without any compromises. This component acts as the bridge, giving you:
- Full Type-Safety: Infer types directly from your validation schemas (like Zod, Valibot, etc.).
- Seamless TanStack Integration: Leverage TanStack Form’s state management and validation logic.
- Consistent shadcn/ui Styling: Use the form components you already know and love.
How to Get Started
Setting it up is straightforward. Follow these steps, and you'll have type-safe, beautiful forms up and running in minutes.
1. Install shadcn/ui
If you haven't already, initialize shadcn/ui in your project.
npx shadcn-ui@latest init
2. Install TanStack Form
Next, add the TanStack Form library.
npm install @tanstack/react-form
3. Copy the Core Components
My solution consists of two key files. Download them from the GitHub repository and place them in the correct folders:
form.tsx: Copy this to your components/ui folder.
form-hook.tsx: Copy this to a hooks folder in your project's root.
4. Add shadcn/ui Components
Finally, add the shadcn/ui components you'll need for your forms.
npx shadcn-ui@latest add button input label form
How to Use It
Once installed, using the component is intuitive. The structure will feel very familiar to anyone who has used shadcn/ui's original form component.
1. Define your Form Logic
First, define your form's schema (using Zod in this example) and create the form instance with the useAppForm
hook. Here you'll set default values, validators, and your onSubmit
handler.
import { useAppForm } from "@/hooks/form-hook";
import { z } from "zod";
const userSchema = z.object({
email: z.string().email("Invalid email address"),
});
export function MyFormComponent() {
const form = useAppForm({
defaultValues: {
email: "",
},
validators: {
onChange: userSchema,
},
onSubmit: async ({ value }) => {
alert(`Hello ${value.email}!`);
},
});
// ...
}
2. Build the Form Component
Now, construct your form using the components provided. The <form.AppForm>
component links your form instance, and <form.AppField>
connects each input to the form state.
Here’s a complete example:
import { useAppForm } from "@/hooks/form-hook";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/form";
const userSchema = z.object({
email: z.string().email("Invalid email address"),
});
export function EmailForm() {
const form = useAppForm({
defaultValues: {
email: "",
},
validators: {
onChange: userSchema,
},
onSubmit: async ({ value }) => {
// Pretend to submit for 500ms
await new Promise((resolve) => setTimeout(resolve, 500));
alert(`Hello ${value.email}!`);
},
});
return (
<form.AppForm>
<Form className="space-y-4">
<form.AppField name="email">
{(field) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Enter your email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FormControl>
<FormDescription>
We'll never share your email with anyone else.
</FormDescription>
<FormMessage />
</FormItem>
)}
</form.AppField>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
)}
</form.Subscribe>
</Form>
</form.AppForm>
);
}
As you can see, <form.Subscribe>
allows you to listen to the form's state, which is perfect for disabling the submit button while the form is invalid or submitting.
Final Thoughts
This component was born out of a real need in my own project, and I believe it can help others who find themselves in the same boat. It provides a clean, robust, and type-safe solution for building forms without having to choose between a great state manager and a great component library.
Give it a try in your next project! I'd love to hear your feedback. Feel free to open an issue or pull request on the GitHub repository. What are your go-to solutions for forms in React? Let me know in the comments below!
Originally posted on my blog
Top comments (0)