TRENDING
A mountain gorilla looks at the camera from green forest undergrowth
October 29, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
October 29, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
October 29, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 29, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 29, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
29 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A flock of snow geese in flight, seen as dark silhouettes against an orange and red sunset sky with layered clouds
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
Hundreds of yellow rubber ducks floating in a river enclosure during a duck race
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 270 Posts
News 273 Posts
Learning Hub 243 Posts
Home/Learning Hub/How to Build Type-Safe React Forms With TanStack Form, Zod, and shadcn/ui
Learning Hub

How to Build Type-Safe React Forms With TanStack Form, Zod, and shadcn/ui

Learn to build a type-safe React form with TanStack Form, Zod, and shadcn/ui, including a real re-render bug caught and fixed with live evidence.

July 29, 2026 16 Min Read
130

Every React developer eventually builds a form that grows past a login screen: a signup flow with a dynamic list of items, validation rules that need to match a backend schema, and inputs that must not slow down as the form grows. The classic approach, one useState object holding every field, re-renders the entire form on every keystroke. A common workaround, switching to useRef for every field, fixes the re-render problem but throws away validation, controlled inputs, and easy testing.

Table Of Content

  • What You Will Build
  • Prerequisites
  • Step 1: Scaffold a New Vite, React, and TypeScript Project
  • Step 2: Install TanStack Form and Zod
  • Step 3: Define Your Validation Schema With Zod
  • Step 4: Set Up Tailwind CSS and Path Aliases for shadcn/ui
  • A note on baseUrl: do not add it
  • Step 5: Initialize shadcn/ui
  • Expect interactive prompts, and what they mean
  • Step 6: Build the Basic Form Fields
  • Step 7: Add a Dynamic List of Teammates With Array Fields
  • Step 8: Extract a Row Component Without Breaking Fine-Grained Reactivity
  • A gotcha while testing this yourself: React StrictMode doubles render counts
  • Step 9: Gate the Submit Button With useStore
  • Common Mistakes and Gotchas
  • Rendering a Zod issue object directly crashes the page
  • The array’s “at least one teammate” rule needs a UI guard, not just a schema rule
  • How to Verify It All Works
  • Next Steps

This tutorial teaches you how to build a real, working signup form using three specific tools together: TanStack Form, a headless form state library; Zod, a schema validation library; and shadcn/ui, a set of accessible, unstyled component primitives you copy into your own project instead of installing as a black-box package. “Headless” means the library manages form state and validation logic but renders no markup or styling itself, you attach it to whatever inputs you want. This combination lets you validate once (with a Zod schema), reuse that schema on the client and later on a server, and keep only the specific input the user is typing into re-rendering, not the whole form.

You will build a team invite form: a workspace name, a workspace slug, and a dynamic list of teammates the user can add or remove, each with a name and an email. Along the way you will hit two real bugs that are easy to introduce by accident: one where extracting a row into its own component silently breaks the library’s fine-grained re-rendering, and one where rendering a validation error the naive way crashes the page. Both are demonstrated with real, captured evidence, not just described, because both were found by actually running this code in a browser while writing this guide.

What You Will Build

A single-page React app with a form that has:

  • Two plain text fields (workspace name, workspace slug) each validated against a Zod schema as the user types.
  • A dynamic array field for teammates, where the user can add and remove rows, each row having its own name and email validation.
  • A submit button that is only enabled once every field in the form is valid, using a targeted subscription to the form’s state rather than re-rendering the whole form on every change.
  • On-screen render counters on the form and on each teammate row, so you can watch, live in the browser, exactly which parts of the UI re-render as you type. This is the same technique used to verify the claims in this tutorial before publishing it.

Prerequisites

  • Node.js 20 or newer and npm. This tutorial was built and tested with Node.js v24.18.0 and npm v11.16.0.
  • Basic familiarity with React (components, props, hooks) and TypeScript (types, generics by sight). You do not need prior experience with TanStack Form, Zod, or shadcn/ui, this tutorial introduces each one.
  • A terminal and a code editor.
  • Comfort running commands like npm install and reading their output.

Package versions used throughout this tutorial: React 19.2.7, Vite 8.1.1, TypeScript 6.0.2, @tanstack/react-form 1.33.2, zod 4.4.3, and the shadcn CLI 4.16.0. If you install newer versions, the concepts still apply, but exact command output (especially from the shadcn CLI, which changed its setup flow recently) may differ from what is shown here.

Step 1: Scaffold a New Vite, React, and TypeScript Project

Create a new project directory and scaffold it with Vite’s React and TypeScript template:

mkdir team-invite-form && cd team-invite-form
npm create vite@latest . -- --template react-ts
npm install

This gives you a working React app with hot module reload. Confirm it before moving on:

npm run dev

Open the printed local URL (typically http://localhost:5173/) in a browser. You should see the default Vite and React starter page. Stop the dev server with Ctrl+C once you have confirmed it, you will restart it later once the form is built.

Step 2: Install TanStack Form and Zod

npm install @tanstack/react-form zod

TanStack Form manages form state: field values, validation results, dirty and touched tracking, and submission. Zod lets you describe the shape data should have (a string of at least 3 characters, a valid email address) as a schema object, and get back either the validated, typed data or a list of exactly what is wrong. The reason to combine them rather than write validation functions by hand is that TanStack Form understands Zod schemas directly (through a shared interface called Standard Schema, which several validation libraries implement), so you write one schema and use it as your source of truth for both validation and the resulting TypeScript types.

Step 3: Define Your Validation Schema With Zod

Create src/lib/schema.ts:

import { z } from 'zod'

export const inviteeSchema = z.object({
  email: z.string().email('Enter a valid email address'),
  displayName: z.string().min(2, 'Name must be at least 2 characters'),
})

export const teamInviteSchema = z.object({
  workspaceName: z.string().min(3, 'Workspace name must be at least 3 characters'),
  workspaceSlug: z
    .string()
    .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Use lowercase letters, numbers, and hyphens only'),
  invitees: z.array(inviteeSchema).min(1, 'Add at least one teammate'),
})

export type TeamInviteValues = z.infer<typeof teamInviteSchema>

Each field gets its own rule and its own human-readable error message. z.infer<typeof teamInviteSchema> derives a TypeScript type directly from the schema, so if you add a field to the schema, the type updates automatically, you never hand-write a type that can drift out of sync with the validation rules. Notice that inviteeSchema is defined separately: you will reuse its individual fields (inviteeSchema.shape.email) to validate one teammate row at a time, rather than re-validating the entire array on every keystroke.

Step 4: Set Up Tailwind CSS and Path Aliases for shadcn/ui

shadcn/ui is not an npm package you import components from. It is a CLI that copies component source files directly into your project (into src/components/ui/), so you own and can edit the code. It requires Tailwind CSS for styling and a @/ import alias pointing at your src/ folder. Install Tailwind’s Vite plugin and Node’s type definitions (needed for the path resolution you’ll add to vite.config.ts):

npm install tailwindcss @tailwindcss/vite
npm install -D @types/node

Add the @/* path alias to both tsconfig.json and tsconfig.app.json. Open tsconfig.json and add a compilerOptions block:

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

A note on baseUrl: do not add it

Most existing shadcn tutorials tell you to also set "baseUrl": "." alongside paths. On TypeScript 6 (used in this tutorial), that produces a build error: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. With "moduleResolution": "bundler" (Vite’s default for new projects), paths alone is enough, TypeScript resolves them relative to the tsconfig file without needing baseUrl. This was caught by actually running tsc while writing this tutorial, not by assumption, so if you are following an older guide and hit that exact deprecation error, this is why.

Now add the same paths entry inside tsconfig.app.json‘s existing compilerOptions block (do not replace the file, just add the paths key alongside what is already there):

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "paths": {
      "@/*": ["./src/*"]
    },
    "target": "es2023",
    ...
  }
}

Next, wire the same alias into Vite’s own module resolution and add the Tailwind plugin. Replace the contents of vite.config.ts:

import path from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

Finally, import Tailwind at the top of src/index.css (keep whatever else is already in that file below this line):

@import "tailwindcss";

Step 5: Initialize shadcn/ui

Run the init command:

npx shadcn@latest init

Expect interactive prompts, and what they mean

As of CLI version 4.16.0, this command first asks you to pick a component library (Base UI, React Aria, or Radix UI, the underlying accessibility primitives shadcn’s components are built on) and then a color and font preset (Nova, Vega, Maia, and several others). This interactive flow is new enough that older tutorials, and the shadcn CLI’s own --help text from a few versions ago, do not mention it. If you want a single non-interactive command that skips both prompts, matching the classic component structure most existing shadcn examples use, run this instead:

npx shadcn@latest init --template vite --base radix --preset nova -y

--base radix picks the original Radix UI primitives (the most widely documented option). --preset nova picks a default color and font theme, you can pick any preset, it only affects visual styling, not behavior. The command validates that Tailwind and your path alias are already working, which is why Step 4 has to come first, running shadcn init before that step fails with No Tailwind CSS configuration found.

On success, it writes a components.json config file, creates src/lib/utils.ts, and adds a Button component under src/components/ui/. Now add the two other primitives this form needs:

npx shadcn@latest add input label

You now have src/components/ui/button.tsx, input.tsx, and label.tsx, plain React components you can open and read, styled with Tailwind classes.

Step 6: Build the Basic Form Fields

Create src/hooks/useRenderCount.ts, a small instrumentation hook used throughout this tutorial to make re-renders visible on screen instead of asking you to trust a claim about them:

import { useRef } from 'react'

export function useRenderCount() {
  const count = useRef(0)
  count.current += 1
  return count.current
}

This works because a ref‘s value survives across renders without itself triggering one: every time React calls this component’s function body, the counter goes up by one, and reading count.current during that same render tells you how many times this exact component instance has rendered so far.

Now create src/components/TeamInviteForm.tsx. Start with the imports, a shared error-display helper, and the two plain text fields:

import { memo } from 'react'
import { useForm, useStore, type ReactFormExtendedApi } from '@tanstack/react-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { useRenderCount } from '@/hooks/useRenderCount'
import { inviteeSchema, teamInviteSchema, type TeamInviteValues } from '@/lib/schema'

const defaultValues: TeamInviteValues = {
  workspaceName: '',
  workspaceSlug: '',
  invitees: [{ email: '', displayName: '' }],
}

function FieldError({ errors }: { errors: unknown[] }) {
  if (errors.length === 0) return null
  const message =
    typeof errors[0] === 'string'
      ? errors[0]
      : ((errors[0] as { message?: string })?.message ?? 'Invalid value')
  return <p className="text-sm text-red-600">{message}</p>
}

The FieldError component looks defensive on purpose, and Step 9 explains exactly why it has to handle two different shapes of error, not just strings.

Next, start the main component and its first field. useForm is the core TanStack Form hook: it takes your default values and a submit handler, and returns an object with a Field component you use to wire up each input.

export function TeamInviteForm() {
  const parentRenders = useRenderCount()

  const form = useForm({
    defaultValues,
    validators: {
      onSubmit: teamInviteSchema,
    },
    onSubmit: async ({ value }) => {
      console.log('Form submitted:', value)
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
      className="max-w-2xl mx-auto text-left space-y-6 p-6"
    >
      <div className="flex items-center justify-between">
        <h2 className="text-xl font-semibold">Invite your team</h2>
        <span className="text-xs text-neutral-400">form renders: {parentRenders}</span>
      </div>

      <form.Field
        name="workspaceName"
        validators={{ onChange: teamInviteSchema.shape.workspaceName }}
      >
        {(field) => (
          <div>
            <Label htmlFor={field.name}>Workspace name</Label>
            <Input
              id={field.name}
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            <FieldError errors={field.state.meta.errors} />
          </div>
        )}
      </form.Field>
    </form>
  )
}

Two things to understand here before adding more fields:

  • validators.onChange takes a Zod schema piece directly. teamInviteSchema.shape.workspaceName pulls just the one sub-schema (a string with a minimum length) out of the larger object schema, so this field is validated on its own, not by re-running validation for the entire form on every keystroke.
  • form.Field is a component, not a plain function. It subscribes internally to just that one field’s slice of form state. That subscription, not anything you write yourself, is what makes typing in this field re-render only this field and not the rest of the form. You will prove this with the render counters once the full form is built.

Add the workspace slug field the same way, directly below the workspace name field and above the closing </form> tag:

<form.Field
  name="workspaceSlug"
  validators={{ onChange: teamInviteSchema.shape.workspaceSlug }}
>
  {(field) => (
    <div>
      <Label htmlFor={field.name}>Workspace slug</Label>
      <Input
        id={field.name}
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      <FieldError errors={field.state.meta.errors} />
    </div>
  )}
</form.Field>

Step 7: Add a Dynamic List of Teammates With Array Fields

TanStack Form supports array fields with mode="array". The field’s render callback gives you the current array plus methods to mutate it: pushValue appends an item, removeValue(index) removes one by position. Add this directly below the workspace slug field, still inside the <form>:

<form.Field name="invitees" mode="array">
  {(invateesField) => (
    <div className="space-y-3">
      <div className="flex items-center justify-between">
        <Label>Teammates</Label>
        <Button
          type="button"
          variant="secondary"
          onClick={() => invateesField.pushValue({ email: '', displayName: '' })}
        >
          Add teammate
        </Button>
      </div>

      {invateesField.state.value.map((_, index) => (
        <InviteeRow
          key={index}
          form={form}
          index={index}
          canRemove={invateesField.state.value.length > 1}
          removeValue={invateesField.removeValue}
        />
      ))}
    </div>
  )}
</form.Field>

This references an InviteeRow component that does not exist yet, and how you write it matters more than it looks. The naive version, a plain function component that closes over an inline arrow function for removal, is exactly the mistake Step 8 walks through and fixes with real evidence.

Step 8: Extract a Row Component Without Breaking Fine-Grained Reactivity

It is tempting to extract each teammate row into its own component purely for readability, and assume that because TanStack Form is described as having “fine-grained reactivity,” each row will automatically stay isolated from its siblings. That assumption was tested directly while writing this tutorial, using a small Puppeteer script that types into one field and reads the on-screen render counters after every keystroke. It was wrong.

Here is what a naive, unmemoized row component looks like:

// Do not copy this version, see the fix below.
function InviteeRow({ form, index, onRemove, canRemove }) {
  const renders = useRenderCount()
  return (
    <div>
      {/* ...fields... */}
      <span>row renders: {renders}</span>
      <Button onClick={onRemove}>Remove</Button>
    </div>
  )
}

// used as:
// <InviteeRow key={index} form={form} index={index} canRemove={...}
//   onRemove={() => invateesField.removeValue(index)} />

Tested against two rows, with each keystroke’s on-screen counters read immediately afterward: typing 3 characters into row 0’s name field took row 0’s own counter from 4 to 8, and row 1’s counter, for a row that was never touched, also climbed from 2 to 6. The value in row 1 stayed correct (typing in row 0 does not corrupt row 1’s data), but row 1 re-rendered anyway, every single keystroke.

The reason is ordinary React behavior, not a TanStack Form bug: the array-mode form.Field around invitees re-renders whenever any item in the array changes, because from its perspective the whole array’s value changed. Its render callback (the .map(...) call) runs again, and that calls InviteeRow as an ordinary function again for every row, every time, because InviteeRow is a plain component with no way to say “skip me if my own props did not change.” The inner form.Field fields inside each row are still correctly isolated, only the ones bound to the field that changed actually update, but the row wrapper you wrote around them is not.

The fix is React.memo, and it only works if every prop you pass is reference-stable across renders. That rules out the inline onRemove={() => invateesField.removeValue(index)} arrow function above: a new function is created every render, so memo‘s prop comparison would see it as “changed” every time and re-render anyway. Pass the array field’s own removeValue method instead (it lives on the array field’s stable internal instance, so its identity does not change between renders) along with the plain index number, and call removeValue(index) from inside the memoized component:

type TeamInviteFormApi = ReactFormExtendedApi<
  TeamInviteValues,
  any, any, any, any, any, any, any, any, any, any, any
>

type InviteeRowProps = {
  form: TeamInviteFormApi
  index: number
  removeValue: (index: number) => void
  canRemove: boolean
}

const InviteeRow = memo(function InviteeRow({ form, index, removeValue, canRemove }: InviteeRowProps) {
  const renders = useRenderCount()

  return (
    <div className="flex gap-3 items-start border border-neutral-200 rounded-lg p-3">
      <div className="flex-1 grid grid-cols-2 gap-3">
        <form.Field
          name={`invitees[${index}].displayName`}
          validators={{ onChange: inviteeSchema.shape.displayName }}
        >
          {(field) => (
            <div>
              <Label htmlFor={field.name}>Name</Label>
              <Input
                id={field.name}
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
              />
              <FieldError errors={field.state.meta.errors} />
            </div>
          )}
        </form.Field>

        <form.Field
          name={`invitees[${index}].email`}
          validators={{ onChange: inviteeSchema.shape.email }}
        >
          {(field) => (
            <div>
              <Label htmlFor={field.name}>Email</Label>
              <Input
                id={field.name}
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
              />
              <FieldError errors={field.state.meta.errors} />
            </div>
          )}
        </form.Field>
      </div>

      <div className="flex flex-col items-end gap-1 pt-6">
        <span className="text-xs text-neutral-400">row renders: {renders}</span>
        <Button type="button" variant="outline" disabled={!canRemove} onClick={() => removeValue(index)}>
          Remove
        </Button>
      </div>
    </div>
  )
})

The TeamInviteFormApi type alias above is its own small gotcha: useForm has eleven generic type parameters beyond your data type, all related to validator function types, and TypeScript cannot cleanly infer a standalone type for “whatever useForm<TeamInviteValues> returns” the way you might expect. Filling the other eleven parameters with any is the pragmatic way to type a prop that only needs to call form.Field and does not care what validator functions were used to build the form.

Re-running the exact same test against the memoized version: typing 3 characters into row 0’s name field left row 0’s own render counter completely unchanged, and row 1’s counter unchanged too. Not “isolated to one render each”, zero additional renders for either row wrapper. The actual DOM update still happens, but it happens inside the innermost form.Field for that one input, which was always correctly isolated, the wrapper around it now gets out of the way instead of re-running on every keystroke in the whole array.

A gotcha while testing this yourself: React StrictMode doubles render counts

If you build this yourself and watch the counters, you will notice they climb by 2 per change instead of 1, and the very first render already shows 2, not 1. That is <StrictMode> in src/main.tsx (present by default in a fresh Vite scaffold), which intentionally double-invokes component render functions in development to help surface impure side effects. It is not a TanStack Form quirk and it does not happen in a production build. Do not remove StrictMode to make the numbers look cleaner, it is doing its job.

Step 9: Gate the Submit Button With useStore

The submit button should be disabled until the whole form is valid. You could read form.state.canSubmit directly in the component body, but that does not subscribe to anything, the component would not know to re-render when it changes. TanStack Form re-exports useStore for exactly this: it takes the form’s internal store and a selector function, and only re-renders your component when the selected value itself changes, not on every unrelated state update.

Add this inside TeamInviteForm, above the return:

const canSubmit = useStore(form.store, (state) => state.canSubmit)
const isSubmitting = useStore(form.store, (state) => state.isSubmitting)

And add the submit button, after the invitees array field and before the closing </form> tag:

<div className="flex items-center gap-3">
  <Button type="submit" disabled={!canSubmit || isSubmitting}>
    {isSubmitting ? 'Sending invites...' : 'Send invites'}
  </Button>
  <span className="text-xs text-neutral-400">canSubmit: {String(canSubmit)}</span>
</div>

This was traced character by character while writing this tutorial, typing “A”, then “d”, then “a” into a name field with a 2-character minimum, reading the parent form’s render counter and the canSubmit text after each key:

start        formRenders: 1   canSubmit: true
after 'A'    formRenders: 2   canSubmit: false   (1 char, fails the 2-char minimum)
after 'd'    formRenders: 3   canSubmit: true     ("Ad" passes, value flipped)
after 'a'    formRenders: 3   canSubmit: true     ("Ada" still passes, value did NOT flip, no re-render)

The parent form only re-rendered when canSubmit‘s actual boolean value changed, not on every keystroke. That third keystroke, the one that did not change whether the form could submit, produced zero additional renders. That is the real, verifiable meaning of “fine-grained reactivity” here: not “never re-renders,” but “re-renders exactly when the thing you subscribed to changes, and not before.”

Common Mistakes and Gotchas

Rendering a Zod issue object directly crashes the page

When you pass a Zod schema (or a piece of one) directly as a validators.onChange value, as this tutorial does, field.state.meta.errors is an array of full Zod issue objects, not plain strings. A captured example, from typing an invalid value into the email field:

[{
  "origin": "string",
  "code": "invalid_format",
  "format": "email",
  "path": [],
  "message": "Enter a valid email address"
}]

If you skip the type check in FieldError and render {errors[0]} directly, expecting a string, React throws this at runtime, confirmed by actually triggering it while writing this tutorial:

Objects are not valid as a React child (found: object with keys {origin, code, format, pattern, path, message}).
If you meant to render a collection of children, use an array instead.

This is why FieldError, back in Step 6, checks typeof errors[0] === 'string' before falling back to reading .message off the object. If you write a custom function validator instead of passing a schema directly (onChange: ({ value }) => someCondition ? 'error text' : undefined), you get a plain string back and do not need this check, but then you lose the direct schema reuse this tutorial is built around.

The array’s “at least one teammate” rule needs a UI guard, not just a schema rule

teamInviteSchema‘s invitees: z.array(inviteeSchema).min(1, ...) only runs as part of the whole-form onSubmit validator, array-level rules like a minimum length are awkward to attach to a per-keystroke onChange validator on the array field itself. In this UI, that rule can never actually fire, because the Remove button is disabled with canRemove={invateesField.state.value.length > 1}, the user can never get down to zero rows in the first place. Keep the schema rule anyway, it is what protects you if this form’s data is ever submitted programmatically or the UI changes later, but do not expect to see its error message while manually testing the happy path.

How to Verify It All Works

Wire the component up in src/App.tsx:

import { TeamInviteForm } from '@/components/TeamInviteForm'

function App() {
  return (
    <div>
      <TeamInviteForm />
    </div>
  )
}

export default App

Then run three checks, in order:

npx tsc --noEmit -p tsconfig.app.json

This should print nothing and exit cleanly. It catches the kind of generic-typing mistakes this tutorial ran into in Step 8, before you ever open a browser.

npm run build

This runs the typecheck again as part of a full production build and bundles the app. It should end with a ✓ built in ...ms line and no errors.

npm run dev

Open the printed local URL. You should see the form with the workspace name and slug fields, one teammate row, an “Add teammate” button, and a disabled “Send invites” button. Confirm the whole flow end to end:

  • Type into the workspace slug field using an uppercase letter or a space. You should see “Use lowercase letters, numbers, and hyphens only” appear immediately below it.
  • Click “Add teammate” twice. Watch each new row’s own render counter start at its own value, independent of the others.
  • Type in one teammate row’s name field and watch the other row’s counter, it should not move.
  • Fill in a valid workspace name (3 or more characters), a valid slug (lowercase, numbers, hyphens), and a valid name and email for every teammate row. The “canSubmit” text should flip to true and the button should become enabled.
  • Click “Send invites” and check your browser’s developer console for the Form submitted: ... log line with your data.

Next Steps

  • Reuse teamInviteSchema on a real backend (a Node.js API route, for example) to validate the same data server-side before writing it anywhere, the whole point of defining validation as data rather than as UI-bound functions is that it is not tied to React at all.
  • For forms much larger than this one, look at TanStack Form’s createFormHookContexts and createFormHook APIs, which build pre-typed, reusable field and form components so you are not repeating the generic type gymnastics from Step 8 in every extracted component.
  • Compare this approach to React Hook Form, the other major library in this space, if you already have forms built with it. The core trade-off is the same one this tutorial demonstrated directly: TanStack Form’s per-field subscriptions give you fine-grained control, but as Step 8 showed, that control is opt-in the moment you extract your own components, not automatic.

Tags:

Reactshadcn/uiTanStack FormTypeScriptZod

Share

Google campus sign reading Google, 1565 and 1585 Charleston Road, B45 and B46, surrounded by redwood trees
Previous Post

Google’s ATLAS Study Turns AI Job-Displacement Fears Into a Measurement Problem

A quadruped robot dog on patrol during a U.S. Space Force security training exercise
Next Post

FCC Blocks New Foreign-Made Humanoid Robots and Power Inverters

No Comment! Be the first one.

Leave a Reply Cancel reply

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

Latest
28 Oct
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
28 Oct
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
Trending
October 28, 2026
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026