Future APIs / PreserveBoundary

PreserveBoundary

The PreserveBoundary component is part of Conform's future export. These APIs are experimental and may change in minor versions. Learn more

A React component that preserves form field values during client-side navigation when React unmounts its contents. Useful for multi-step wizards, form dialogs, and virtualized lists where fields are temporarily hidden but should still be included in form submission.

1import { PreserveBoundary } from '@conform-to/react/future';
2
3{
4  step === 1 ? (
5    <PreserveBoundary name="step-1">
6      <input name="name" />
7      <input name="email" />
8    </PreserveBoundary>
9  ) : step === 2 ? (
10    <PreserveBoundary name="step-2">
11      <input name="address" />
12      <input name="city" />
13    </PreserveBoundary>
14  ) : null;
15}

#Props

name: string

A unique name for the boundary within the form. This is used to:

  1. Ensure proper unmount/remount behavior when switching between boundaries in conditional rendering
  2. Isolate preserved inputs so they don't conflict with inputs from other boundaries

form?: string

The id of the form to associate with. Only needed when the boundary is rendered outside the form element:

1<form id="my-form">
2  <button type="submit">Submit</button>
3</form>;
4{
5  /* Boundary outside the form */
6}
7<PreserveBoundary name="external" form="my-form">
8  <input name="field" />
9</PreserveBoundary>;

#Examples

Multi-step form

1import { useForm, PreserveBoundary } from '@conform-to/react/future';
2import { useState } from 'react';
3
4function MultiStepForm() {
5  const [step, setStep] = useState(1);
6  const { form, fields } = useForm();
7
8  return (
9    <form {...form.props}>
10      {step === 1 ? (
11        <PreserveBoundary name="step-1">
12          <label>
13            Name
14            <input
15              name={fields.name.name}
16              defaultValue={fields.name.defaultValue}
17            />
18          </label>
19          <label>
20            Email
21            <input
22              name={fields.email.name}
23              defaultValue={fields.email.defaultValue}
24            />
25          </label>
26        </PreserveBoundary>
27      ) : step === 2 ? (
28        <PreserveBoundary name="step-2">
29          <label>
30            Address
31            <input
32              name={fields.address.name}
33              defaultValue={fields.address.defaultValue}
34            />
35          </label>
36          <label>
37            City
38            <input
39              name={fields.city.name}
40              defaultValue={fields.city.defaultValue}
41            />
42          </label>
43        </PreserveBoundary>
44      ) : null}
45
46      <div>
47        {step > 1 ? (
48          <button type="button" onClick={() => setStep(step - 1)}>
49            Previous
50          </button>
51        ) : null}
52        {step < 2 ? (
53          <button type="button" onClick={() => setStep(step + 1)}>
54            Next
55          </button>
56        ) : step === 2 ? (
57          <button type="submit">Submit</button>
58        ) : null}
59      </div>
60    </form>
61  );
62}

Virtualized list

For virtualized lists, each item should use name with a stable identifier to ensure values are correctly associated with their items:

1import { useForm, PreserveBoundary } from '@conform-to/react/future';
2import { useVirtualizer } from '@tanstack/react-virtual';
3
4function VirtualizedItemList({ items }) {
5  const { form, fields } = useForm({
6    defaultValue: { items },
7  });
8
9  const parentRef = useRef(null);
10  const virtualizer = useVirtualizer({
11    count: items.length,
12    getScrollElement: () => parentRef.current,
13    estimateSize: () => 50,
14  });
15
16  const itemFields = fields.items.getFieldList();
17
18  return (
19    <form {...form.props}>
20      <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
21        <div
22          style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
23        >
24          {virtualizer.getVirtualItems().map((virtualRow) => {
25            const itemField = itemFields[virtualRow.index];
26            return (
27              <div
28                key={virtualRow.key}
29                style={{
30                  position: 'absolute',
31                  top: virtualRow.start,
32                  height: virtualRow.size,
33                }}
34              >
35                <PreserveBoundary name={`item-${virtualRow.index}`}>
36                  <input
37                    name={itemField.name}
38                    defaultValue={itemField.defaultValue}
39                  />
40                </PreserveBoundary>
41              </div>
42            );
43          })}
44        </div>
45      </div>
46      <button type="submit">Save All</button>
47    </form>
48  );
49}

#Tips

Only use for navigational conditions

The condition that unmounts a PreserveBoundary should be navigational (step changes, dialog open/close), not when the user is intentionally excluding data from the submission. Otherwise, the preserved values will still be submitted even when the user hides the field:

1// Don't do this: the discount code will still be submitted even when hidden
2{
3  hasDiscountCode && (
4    <PreserveBoundary name="discount">
5      <input name="discountCode" />
6    </PreserveBoundary>
7  );
8}
9
10// Do this instead: let the field unmount normally
11{
12  hasDiscountCode && <input name="discountCode" />;
13}

Stale values are cleaned up automatically

When the fields inside a boundary change based on external state, stale preserved values are automatically removed on remount. For example, if the user fills in companyName/jobTitle on step 2, goes back to step 1, and switches account type to "personal", the preserved business fields are removed when step 2 remounts:

1{
2  step === 1 ? (
3    <PreserveBoundary name="step-1">
4      <select name="accountType">
5        <option value="personal">Personal</option>
6        <option value="business">Business</option>
7      </select>
8    </PreserveBoundary>
9  ) : step === 2 ? (
10    <PreserveBoundary name="step-2">
11      {accountType === 'personal' ? (
12        <input name="dateOfBirth" type="date" />
13      ) : accountType === 'business' ? (
14        <>
15          <input name="companyName" />
16          <input name="jobTitle" />
17        </>
18      ) : null}
19    </PreserveBoundary>
20  ) : null;
21}