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:
- A dynamic list of interests they can add and remove
- A notification preference (email, SMS, or none)
- 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.