Lesson 18

Building Step 2: Preferences and Dynamic Fields

Step 2 of the capstone is where things get interesting. We're building StepPreferences, which pulls together two patterns from earlier in the course: array fields (Lesson 10) and linked/conditional fields (Lesson 11). If those felt abstract before, this is where they click into place.

What StepPreferences Covers

This step collects three things from the user:

  1. A dynamic list of interests they can add and remove
  2. A notification preference (email, SMS, or none)
  3. A phone number — but only if they chose SMS

That last one is the key. The phone field appears and disappears based on another field's value, and it only validates when it's visible.

The Interests Array Field

Back in Lesson 10 we looked at mode="array" for managing lists of values. Here's that pattern applied directly:

<form.Field
  name="interests"
  mode="array"
  children={(field) => (
    <div>
      <h3>Interests</h3>
      {field.state.value.map((_, i) => (
        <div key={i} style={{ display: 'flex', gap: '8px' }}>
          <form.Field
            name={`interests[${i}]`}
            children={(subField) => (
              <input
                value={subField.state.value}
                onChange={(e) => subField.handleChange(e.target.value)}
                placeholder="e.g., React, TypeScript..."
              />
            )}
          />
          <button type="button" onClick={() => field.removeValue(i)}>
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={() => field.pushValue('')}>
        Add Interest
      </button>
    </div>
  )}
/>

The outer field owns the array. Each item gets its own nested form.Field keyed by index. pushValue appends an empty string, removeValue drops an item by index. The form state stays fully reactive — no manual array juggling required.

Notification Preference Select

A straightforward select field. The value drives whether the phone field renders at all:

<form.Field
  name="notificationPreference"
  children={(field) => (
    <div>
      <label>Notification Preference</label>
      <select
        value={field.state.value}
        onChange={(e) => field.handleChange(e.target.value)}
      >
        <option value="email">Email</option>
        <option value="sms">SMS</option>
        <option value="none">None</option>
      </select>
    </div>
  )}
/>

Nothing unusual here — just a controlled select wired up with handleChange.

Conditional Phone Field

This is where Lesson 11 comes back. We use form.Subscribe to watch notificationPreference and conditionally render the phone field. The validator on the phone field only fires when the field is mounted, so it won't block submission when SMS isn't selected:

<form.Subscribe
  selector={(state) => state.values.notificationPreference}
  children={(pref) =>
    pref === 'sms' ? (
      <form.Field
        name="phone"
        validators={{
          onBlur: ({ value }) =>
            !value ? 'Phone number is required for SMS' : undefined,
        }}
        children={(field) => (
          <div>
            <label>Phone Number</label>
            <input
              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>
        )}
      />
    ) : null
  }
/>

form.Subscribe re-renders only when notificationPreference changes. When pref is not 'sms', the phone field unmounts entirely — no ghost validation, no lingering state.

The Complete StepPreferences Component

Put it all together:

function StepPreferences({ form }) {
  return (
    <div>
      <h2>Preferences</h2>

      <form.Field
        name="interests"
        mode="array"
        children={(field) => (
          <div>
            <h3>Interests</h3>
            {field.state.value.map((_, i) => (
              <div key={i} style={{ display: 'flex', gap: '8px' }}>
                <form.Field
                  name={`interests[${i}]`}
                  children={(subField) => (
                    <input
                      value={subField.state.value}
                      onChange={(e) => subField.handleChange(e.target.value)}
                      placeholder="e.g., React, TypeScript..."
                    />
                  )}
                />
                <button type="button" onClick={() => field.removeValue(i)}>
                  Remove
                </button>
              </div>
            ))}
            <button type="button" onClick={() => field.pushValue('')}>
              Add Interest
            </button>
          </div>
        )}
      />

      <form.Field
        name="notificationPreference"
        children={(field) => (
          <div>
            <label>Notification Preference</label>
            <select
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            >
              <option value="email">Email</option>
              <option value="sms">SMS</option>
              <option value="none">None</option>
            </select>
          </div>
        )}
      />

      <form.Subscribe
        selector={(state) => state.values.notificationPreference}
        children={(pref) =>
          pref === 'sms' ? (
            <form.Field
              name="phone"
              validators={{
                onBlur: ({ value }) =>
                  !value ? 'Phone number is required for SMS' : undefined,
              }}
              children={(field) => (
                <div>
                  <label>Phone Number</label>
                  <input
                    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>
              )}
            />
          ) : null
        }
      />
    </div>
  )
}

What to Notice

The three sections are independent but the second and third are linked. Changing the select immediately affects what renders below it. The phone field's validator only runs when the field exists — switch away from SMS and that validation requirement disappears with it.

Next step we'll wire up submission and handle the final review screen.