Lesson 11

Linked and Dependent Fields

Most forms have at least one field that depends on another. A shipping address that mirrors billing. A phone number that only matters when the user picks "call me." An extra field that unlocks when a checkbox is checked. These are linked fields — their visibility, value, or validation is driven by what's happening elsewhere in the form.

What Makes Fields Linked

A field is linked when one of these things is true:

  • It's only visible when another field has a certain value
  • Its validation rules change based on another field
  • Its value should be copied or derived from another field

The tool TanStack Form gives you for all three is form.Subscribe. It lets you read any slice of form state and react to it in your render.

Conditional Rendering

The most common pattern is showing or hiding a field based on a selection. Wrap the dependent fields in form.Subscribe and use the selector to pull out the controlling value:

<form.Subscribe
  selector={(state) => state.values.contactMethod}
  children={(contactMethod) => (
    <>
      {contactMethod === 'email' && (
        <form.Field
          name="email"
          children={(field) => (
            <input
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
          )}
        />
      )}
      {contactMethod === 'phone' && (
        <form.Field
          name="phone"
          children={(field) => (
            <input
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
          )}
        />
      )}
    </>
  )}
/>

Only the relevant input renders. The others don't exist in the DOM, so their values don't interfere with submission.

Conditional Validation

Visibility alone isn't always enough. Sometimes you need validation rules that depend on context. Use form.getFieldValue() inside a validator to read another field's current value at the moment validation runs:

<form.Field
  name="phone"
  validators={{
    onBlur: ({ value }) => {
      const method = form.getFieldValue('contactMethod')
      if (method === 'phone' && !value) return 'Phone is required'
      return undefined
    },
  }}
  children={(field) => (
    <input
      value={field.state.value}
      onChange={(e) => field.handleChange(e.target.value)}
      onBlur={field.handleBlur}
    />
  )}
/>

The phone field only fails validation when the user actually chose "phone" as their contact method. Every other time, the validator returns undefined and stays silent.

The "Same As" Pattern

A checkbox that copies values from one group of fields to another is a classic UI pattern — most commonly "shipping address same as billing." When the checkbox is checked, you programmatically set the shipping fields to match the billing values. When unchecked, you clear them so the user can enter different ones.

<form.Field
  name="sameAsBilling"
  children={(field) => (
    <label>
      <input
        type="checkbox"
        checked={field.state.value}
        onChange={(e) => {
          field.handleChange(e.target.checked)
          if (e.target.checked) {
            form.setFieldValue('shippingCity', form.getFieldValue('billingCity'))
            form.setFieldValue('shippingZip', form.getFieldValue('billingZip'))
          } else {
            form.setFieldValue('shippingCity', '')
            form.setFieldValue('shippingZip', '')
          }
        }}
      />
      Same as billing address
    </label>
  )}
/>

The checkbox field drives the state. form.getFieldValue reads the source, form.setFieldValue writes to the target. No separate state management needed.

Complete Example

Here's a contact form where the relevant input appears based on the selected contact method:

function ContactForm() {
  const form = useForm({
    defaultValues: {
      contactMethod: 'email',
      email: '',
      phone: '',
      mailingAddress: '',
    },
    onSubmit: async ({ value }) => console.log(value),
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="contactMethod"
        children={(field) => (
          <select
            value={field.state.value}
            onChange={(e) => field.handleChange(e.target.value)}
          >
            <option value="email">Email</option>
            <option value="phone">Phone</option>
            <option value="mail">Mail</option>
          </select>
        )}
      />

      <form.Subscribe
        selector={(state) => state.values.contactMethod}
        children={(contactMethod) => (
          <>
            {contactMethod === 'email' && (
              <form.Field
                name="email"
                validators={{
                  onBlur: ({ value }) => (!value ? 'Email is required' : undefined),
                }}
                children={(field) => (
                  <div>
                    <input
                      placeholder="Email address"
                      value={field.state.value}
                      onChange={(e) => field.handleChange(e.target.value)}
                      onBlur={field.handleBlur}
                    />
                    {field.state.meta.errors.length > 0 && (
                      <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
                    )}
                  </div>
                )}
              />
            )}
            {contactMethod === 'phone' && (
              <form.Field
                name="phone"
                validators={{
                  onBlur: ({ value }) => (!value ? 'Phone is required' : undefined),
                }}
                children={(field) => (
                  <div>
                    <input
                      placeholder="Phone number"
                      value={field.state.value}
                      onChange={(e) => field.handleChange(e.target.value)}
                      onBlur={field.handleBlur}
                    />
                    {field.state.meta.errors.length > 0 && (
                      <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
                    )}
                  </div>
                )}
              />
            )}
            {contactMethod === 'mail' && (
              <form.Field
                name="mailingAddress"
                validators={{
                  onBlur: ({ value }) => (!value ? 'Mailing address is required' : undefined),
                }}
                children={(field) => (
                  <div>
                    <input
                      placeholder="Mailing address"
                      value={field.state.value}
                      onChange={(e) => field.handleChange(e.target.value)}
                      onBlur={field.handleBlur}
                    />
                    {field.state.meta.errors.length > 0 && (
                      <p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
                    )}
                  </div>
                )}
              />
            )}
          </>
        )}
      />

      <button type="submit">Submit</button>
    </form>
  )
}

form.Subscribe watches the contactMethod value and re-renders only the dependent section when it changes. Each field carries its own validation but only appears — and only validates — when it's actually relevant. This keeps the form focused and the user experience clean.