ESC

Type to search the knowledge base.

Transfer List

Two lists with move selected, move all, and optional reorder. Selection model, disabled empty actions, and keyboard between panes.

intermediate7 min read
  • machine-coding
  • interview
  • react
  • a11y

A transfer list looks like a form control from 2004 and still shows up because it is a state machine with sharp edges. Two panes, some items highlighted, four verbs. Candidates store selected: boolean on each item, mutate both arrays, and leave Move enabled when the highlight lives in the other pane. The model is two ordered arrays and one selected id set. Buttons disable from those facts.

Keyboard is the other half: you can Tab to a pane, move the highlight, Space to select, then activate Move. A pair of mute <div>s with click-only rows fails.

Key takeaways

  • left: Item[], right: Item[], selected: Set<id>. Ids are unique across both panes.
  • Four verbs: selected right, selected left, all right, all left.
  • Disable a verb when it would move nothing.
  • Move preserves relative order from the source list, then appends.
  • Clear selection of ids that left their pane, or keep them selected on the destination. Pick one and say it.

Two arrays, a selected set, four verbs

Do not keep one array with side: "left" | "right". You will sort on every render and then fight insertion order. Two arrays match the UI and make “move all” a splice.

type Item = { id: string; label: string };

type TransferState = {
  left: Item[];
  right: Item[];
  selected: Set<string>;
};

function partition(list: Item[], selected: Set<string>) {
  const moving: Item[] = [];
  const staying: Item[] = [];
  for (const item of list) {
    (selected.has(item.id) ? moving : staying).push(item);
  }
  return { moving, staying };
}

function moveSelected(
  from: Item[],
  to: Item[],
  selected: Set<string>
): { from: Item[]; to: Item[]; selected: Set<string> } {
  const { moving, staying } = partition(from, selected);
  const movedIds = new Set(moving.map((i) => i.id));
  const nextSelected = new Set(selected);
  for (const id of movedIds) nextSelected.delete(id);
  return { from: staying, to: [...to, ...moving], selected: nextSelected };
}

function moveAll(from: Item[], to: Item[]) {
  return { from: [], to: [...to, ...from] };
}

Selection is not the same as membership. Highlighted ids can exist in left or right. A single Set is enough if ids are globally unique. If the same label can appear on both sides, you still need unique ids.

On move, I clear those ids from selected so the destination does not come in already highlighted. The other valid product choice is to keep them selected on the new side so a round-trip is one click. Interviewers accept either. They do not accept Move remaining enabled with an empty selection.

Disable rules:

Button Disabled when
> selected to right no selected id in left
< selected to left no selected id in right
>> all to right left.length === 0
<< all to left right.length === 0

Problem statement

Build a transfer list: two lists (Available / Chosen) and controls to move the current selection or the entire pane. Optional reorder inside a pane. Keyboard must reach rows and actions. Empty actions disable.

Requirements

Must have

  • Two lists from data
  • Multi-select rows (click, Shift optional)
  • Move selected both directions
  • Move all both directions
  • Disable no-op buttons
  • Accessible names on lists and buttons

Should have

  • Controlled left / right + onChange
  • Keyboard: listbox pattern or checkbox group per pane
  • Announce after a move (aria-live)

Nice to have

  • Reorder within a pane (buttons or drag)
  • Filter / search per pane
  • Counts in pane labels

Planning (5 minutes out loud)

  1. Two arrays + selected set, never selected flags on items
  2. Four functions, one per button, all returning next state
  3. MVP: click to toggle select, four buttons, disable rules
  4. Then listbox roles or keep native checkboxes (honest and fast)
  5. Reorder last

Native checkboxes inside each pane are a legal shortcut. You get multi-select and names for free. A listbox is the APG dual-listbox pattern and needs roving tabindex plus aria-selected. If time is tight, checkboxes, and say you know the listbox version.

Architecture

TransferList
├── Pane (label, list, optional filter)
│   └── Row × n
├── Actions
│   ├── moveRight / moveAllRight
│   └── moveLeft / moveAllLeft
└── Pane

API

type TransferListProps = {
  left: Item[];
  right: Item[];
  onChange: (next: { left: Item[]; right: Item[] }) => void;
  leftLabel?: string;
  rightLabel?: string;
};

Selection can stay internal. The parent owns membership.

Implementation sketch

function TransferList({
  left,
  right,
  onChange,
  leftLabel = "Available",
  rightLabel = "Chosen",
}: TransferListProps) {
  const [selected, setSelected] = useState<Set<string>>(new Set());

  function toggle(id: string) {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  const leftSelected = left.some((i) => selected.has(i.id));
  const rightSelected = right.some((i) => selected.has(i.id));

  function apply(
    nextLeft: Item[],
    nextRight: Item[],
    nextSelected: Set<string>
  ) {
    setSelected(nextSelected);
    onChange({ left: nextLeft, right: nextRight });
  }

  return (
    <div className="transfer">
      <Pane
        label={leftLabel}
        items={left}
        selected={selected}
        onToggle={toggle}
      />
      <div className="actions">
        <button
          type="button"
          disabled={!leftSelected}
          onClick={() => {
            const r = moveSelected(left, right, selected);
            apply(r.from, r.to, r.selected);
          }}
        >
          Move to chosen
        </button>
        <button
          type="button"
          disabled={left.length === 0}
          onClick={() => {
            const r = moveAll(left, right);
            apply(r.from, r.to, new Set());
          }}
        >
          Move all to chosen
        </button>
        <button
          type="button"
          disabled={!rightSelected}
          onClick={() => {
            const r = moveSelected(right, left, selected);
            apply(r.to, r.from, r.selected);
          }}
        >
          Move to available
        </button>
        <button
          type="button"
          disabled={right.length === 0}
          onClick={() => {
            const r = moveAll(right, left);
            apply(r.to, r.from, new Set());
          }}
        >
          Move all to available
        </button>
      </div>
      <Pane
        label={rightLabel}
        items={right}
        selected={selected}
        onToggle={toggle}
      />
    </div>
  );
}

function Pane({
  label,
  items,
  selected,
  onToggle,
}: {
  label: string;
  items: Item[];
  selected: Set<string>;
  onToggle: (id: string) => void;
}) {
  return (
    <fieldset>
      <legend>{label}</legend>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={selected.has(item.id)}
                onChange={() => onToggle(item.id)}
              />
              {item.label}
            </label>
          </li>
        ))}
      </ul>
    </fieldset>
  );
}

Tab order: left pane, action buttons, right pane. That is keyboard between panes without custom focus traps. If you switch to listboxes, each pane is one tab stop and arrows move aria-activedescendant.

Reorder: “Up” / “Down” on the focused pane’s selected item. Same as a tiny list reorder. Do not start with HTML5 drag.

Accessibility essentials

Piece Approach
Panes fieldset/legend or labelled listbox
Rows Checkbox + label, or role="option" + aria-selected
Actions Buttons with verbs, not > alone without a name
Disabled disabled on the button, not onClick no-op
Result Optional live “Moved 3 items to Chosen”

Icon-only chevrons need aria-label. Color on the selected row is not enough without the checkbox or aria-selected.

Interview scoring

Signal What they want
Model Two arrays, one selected set
Verbs Four functions, disable rules
Order Stable relative order on move
Keys Ids, not index, across moves
A11y Named buttons, labelled panes, keyboard path
Scope Reorder is extra, dual-listbox is named if skipped

They will click Move with nothing selected and watch the button. They will move two items and check that a third stayed. They will ask where selection goes after the move.

Footguns

  1. > as the accessible name
  2. Move enabled with empty selection
  3. Mutating left with splice and missing onChange
  4. Selected flags on items that travel and come back doubled
  5. key={index} so selection paints the wrong row after a move
  6. One listbox role wrapping both panes

Interview out-loud answer

I would keep available and chosen as two arrays and a Set of selected ids. Four verbs: move selected either way, move all either way. Buttons disable when the source has nothing to send. Move pulls selected items in existing order and appends them to the destination, then clears those ids from the set so the highlight does not silently follow. Checkboxes in a fieldset are a valid MVP; a dual listbox is the APG upgrade. Reorder is a separate index shift if they ask.

Further reading