Adding Confirm and Cancel buttons to a multiselect changes more than its footer. A checkbox click can’t also be the moment the form changes. The panel needs its own draft, the closed trigger still has to show the committed value, and closing the panel raises an awkward question: is that draft finished, or just hidden?

I’d already worked through this interaction in Angular with ControlValueAccessor. I built the React version as a companion, using shadcn/ui’s Radix-based components and connecting the same control to TanStack Form and React Hook Form. The form integrations are thin adapters. The real work is defining the editing session and deciding when something outside the component should end it.

The snippets below omit styling and most of the panel markup.

First, decide what Cancel means

Here’s the behavior I wanted. The last row is especially important:

Action Draft Committed form value
Toggle a checkbox or clear the selection Changes locally Unchanged
Confirm Becomes the committed selection Updated if different
Cancel Discarded Unchanged
Escape, outside click, or click the trigger again Retained for the next time it opens Unchanged

Dismissal pauses the edit. Cancel rolls it back. That distinction lets someone check a few options, close the panel to inspect something else, and return without losing their work. It also means the trigger and the open panel can show different selections. The trigger always represents the form; only the panel represents the draft.

Model an editing session

The parent owns the committed value. The component owns everything about the current attempt to change it:

const [session, setSession] = useState<{
baseline: readonly string[];
disabled: boolean;
draft: string[] | null;
open: boolean;
}>({ baseline: [...value], disabled, draft: null, open: false });

The nullable draft matters. null means there’s no independent draft, so the panel uses the committed value. [] means the user deliberately cleared every option. If those two states share a representation, dismissing and reopening an empty draft mysteriously brings the old selection back. Few users will read that as a feature.

baseline is the committed selection the component last received or just confirmed. The guard below uses it to recognize external writes. Keeping the related session fields together lets one setSession call replace the whole editing attempt.

This state lives above the popover content. Radix unmounts that content when the panel closes; state stored inside it would disappear at exactly the moment I want to preserve the draft.

Derive the draft and publish on Confirm

The effective selection is derived during render rather than copied into more state:

const selected = new Set(session.draft ?? value);
const draft = options
.filter((option) => selected.has(option.value))
.map((option) => option.value);
const pending = !sameSelection(draft, value);

Here, sameSelection compares two selections as sets. The draft follows option order, but the parent’s value doesn’t have to. Comparing the arrays directly would treat ['typescript', 'react'] and ['react', 'typescript'] as different selections. This assumes each option’s value is unique.

Filtering the selected IDs through the complete options array gives the draft a canonical order, removes duplicates, and excludes values whose options have disappeared. This has to use all options, not only the current search results. Searching for “React” should hide the other checkboxes, not quietly deselect them.

The resulting pending flag describes the difference between the effective draft and the committed value. There’s no separate boolean to update after every checkbox, Clear button, cancellation, or external write.

Confirm is the only path that publishes a user edit:

function confirm() {
if (disabled) return;
setSession({ baseline: [...draft], disabled, draft: null, open: false });
setQuery('');
if (pending) onValueChange(draft);
onBlur();
}

Toggling a checkbox or pressing Clear only replaces session.draft. Cancel replaces the session with one based on value, while ordinary dismissal changes only open and retains the draft.

When the parent stores a confirmed selection and rerenders, the new value matches baseline. The guard in the next section therefore ignores this expected update instead of treating it as an external write.

Pending changes and form dirtiness are different. After a user confirms a new selection, pending is false because the draft and form agree. The form can still consider itself dirty; confirmation is exactly when it receives the new value. Treating pending as the dirty flag would hide a real confirmed change.

What happens when the parent changes the value?

A parent can replace the value while a draft exists. For this implementation, I chose a simple policy: an external value change wins. It closes the panel and discards the old draft. Changing the disabled state does the same. A parent rerender with an equivalent selection doesn’t interrupt the session.

if (
!sameSelection(session.baseline, value) ||
session.disabled !== disabled
) {
setSession({ baseline: [...value], disabled, draft: null, open: false });
}

This is the guarded render-time adjustment described in the React documentation. The update targets this component’s own state, and the condition converges: once the baseline and disabled snapshot match, the next render makes no further update. React rerenders before rendering the children, so the panel doesn’t receive the invalidated draft. An effect would wait until after the render that observed the new props and only then correct the session.

There’s still one case this comparison can’t detect. Suppose the committed value already equals the defaults and the user has an unconfirmed draft saved. Calling form.reset() leaves the selection unchanged, so there’s no new value for the control to notice. Yet reset should clear that draft.

The parent can make reset an explicit change of component identity:

function reset() {
form.reset();
controls.setRevision((version) => version + 1);
}
// In the form's render:
<ConfirmableMultiselect key={controls.revision} /* other props omitted */ />

Incrementing revision changes the React key, which remounts the control and clears all local state: draft, search query, and open state. During ordinary editing the key stays stable, so parent rerenders don’t destroy the session. The key isn’t a general substitute for controlled props; it supplies the reset signal that an unchanged value can’t carry.

The form only needs an adapter

With the editing rules inside the control, React Hook Form only needs to connect its field contract:

<Controller
name="technologies"
control={form.control}
render={({ field }) => (
<ConfirmableMultiselect
ref={field.ref}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>

The actual adapter also passes the ID, options, disabled state, and validation ARIA props. field.value remains the committed value, field.onChange receives confirmations, and field.onBlur marks the interaction complete. Forwarding field.ref to the trigger button lets React Hook Form focus it after an invalid submission. In this React 19 implementation, ref is an ordinary prop on the component.

TanStack Form needs the same three connections under different names: field.state.value, field.handleChange, and field.handleBlur. The multiselect itself imports neither form library.

For this composite field, I treat Confirm, Cancel, dismissal, and leaving a closed trigger as interaction completion. Moving focus from the trigger into the panel shouldn’t mark the field touched yet, and neither should toggling a checkbox. Both adapters validate the committed value, not the temporary draft. React Hook Form’s isDirty compares the current value with its defaults. TanStack Form’s isDirty records whether the field has changed since reset, while isDefaultValue handles the comparison with defaults.

Test the transaction boundaries

A useful behavioral test checks what the user can observe across the component boundary. In this excerpt, the panel is already open with ['react', 'typescript'] committed. The expectValue helper reads a committed-value readout on the test page:

await panel.getByRole('checkbox', { name: 'Next.js', exact: true }).click();
await expectValue(card, ['react', 'typescript']);
await page.keyboard.press('Escape');
await trigger.click();
await expect(
panel.getByRole('checkbox', { name: 'Next.js', exact: true }),
).toBeChecked();

The checkbox changes, the form doesn’t, and the draft survives the panel unmounting. That single sequence would catch both an accidental immediate form update and state held inside the popover content.

The same behavioral suite runs against both form adapters. Other cases cover Cancel discarding edits, Confirm publishing them, empty drafts surviving dismissal, and same-value resets clearing saved drafts. Loading a new value over a saved draft and disabling the control also have tests, as does removing an option that appears in a saved draft. Keyboard tests cover opening the panel, toggling options, confirming, and returning focus to the trigger.

What changes from the Angular version?

The draft and committed-value problem is the same. The integration contract isn’t:

Concern Angular CVA React control
Receive the committed value writeValue() value prop
Publish confirmation Registered change callback onValueChange
Report interaction completion Registered touched callback Adapter’s onBlur
Connect to forms Framework CVA protocol Form-library adapters

I went into this experiment hoping to find a more elegant or simpler solution in React than in Angular. Looking back, the complexity has mostly moved to a different place, and Angular has caught up with computed signals. Angular provides a standard forms protocol through ControlValueAccessor, while React gets the same separation through controlled props and a small adapter for the chosen form library. Neither makes the transaction rules automatic, and the two posts aren’t an even comparison. I wrote the Angular version first, with plain mutable fields and a mirror FormControl to feed the inner PrimeNG component. Today I’d build it on signals: draft as a nullable signal, with the displayed selection and the pending flag as computed values. That’s the same derived-state shape as this React version, and it removes the mirror control and most of the manual syncing. The remaining Angular overhead is the fixed CVA boilerplate, which buys form integration without per-library adapters or a key to force resets.