Build a transfer list: two side-by-side lists with arrow buttons that move selected items from one to the other. The clean model is a single array of items, each tagged with which side it's on, plus a Set of selected ids — the two visible lists are just filters of that array, and moving means flipping side.
type Item = { id: string; label: string; side: 'left' | 'right' };
// A self-contained component. No props.
function App(): JSX.Element;
Two lists, a › button, and a ‹ button.
select HTML + CSS on the left, click ›
→ both move to the right list; selection clears
left = items.filter(i => i.side === 'left')
right = items.filter(i => i.side === 'right')
side per item. The two lists are filters; don't keep two separate arrays in sync.Set of ids. Clicking toggles membership; highlight reflects it.side. Map the items, change side for selected ids, then clear the selection.Two lists, but one array. Each item carries which side it's on, the two columns are filters of that array, and "selected" is a Set of ids. Moving items is one map that flips side for the selected ids.
The obvious model is two arrays — left and right — and moving an item means removing it from one and pushing it into the other. That's two mutations that must stay consistent, and "what's selected" has to be tracked per list too. It's much simpler to keep all items in one array where each remembers its side. The columns become filter calls, selection is a single Set of ids spanning both lists, and a move just rewrites the side of the selected items.
State: items (each { id, label, side }) and selected (a Set<string>). left and right are items.filter(i => i.side === 'left' | 'right'). Clicking an item toggles its id in selected. The › button maps over items, setting side: 'right' for every selected id; ‹ does the same with 'left'. After a move, clear the selection.
The two-array version looks natural but multiplies the bookkeeping:
const [left, setLeft] = useState([...]);
const [right, setRight] = useState([...]);
function moveRight(item) {
setLeft(left.filter((i) => i !== item));
setRight([...right, item]); // two updates that must agree
}
Every move touches both arrays, and you need selection state for each list separately. Miss one update and an item is in both lists or neither. Folding everything into one array with a side makes a move a single map and selection a single Set.
import { useState } from 'react';
import './styles.css';
type Item = { id: string; label: string; side: 'left' | 'right' };
const INITIAL: Item[] = [
{ id: 'html', label: 'HTML', side: 'left' },
{ id: 'css', label: 'CSS', side: 'left' },
{ id: 'js', label: 'JavaScript', side: 'left' },
{ id: 'react', label: 'React', side: 'right' },
{ id: 'vue', label: 'Vue', side: 'right' },
];
export default function App() {
const [items, setItems] = useState<Item[]>(INITIAL);
const [selected, setSelected] = useState<Set<string>>(new Set());
const left = items.filter((i) => i.side === 'left');
const right = items.filter((i) => i.side === 'right');
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function move(to: 'left' | 'right') {
setItems(items.map((i) => (selected.has(i.id) ? { ...i, side: to } : i)));
setSelected(new Set());
}
function column(title: string, list: Item[]) {
return (
<div className="list">
<div className="list-head">{title}</div>
<ul className="list-body">
{list.map((i) => (
<li
key={i.id}
className={selected.has(i.id) ? 'item selected' : 'item'}
onClick={() => toggle(i.id)}
>
{i.label}
</li>
))}
</ul>
</div>
);
}
return (
<main className="container">
<h1>Transfer List</h1>
<div className="transfer">
{column('Available', left)}
<div className="controls">
<button aria-label="Move right" onClick={() => move('right')}>
›
</button>
<button aria-label="Move left" onClick={() => move('left')}>
‹
</button>
</div>
{column('Selected', right)}
</div>
</main>
);
}
left and right are derived by filter on every render, so they can't disagree with items. toggle flips an id in the selected set (copying first for a new reference). move(to) is the heart: one map sets side: to for selected ids and leaves the rest untouched, then setSelected(new Set()) clears the highlight. Selected items already on the target side simply stay there. The column helper renders either list with the same markup, applying the selected class from the shared set.
Left = [HTML, CSS, JS], right = [React, Vue], nothing selected.
toggle('html') then toggle('css') → selected = {html, css}. Both rows highlight (the class comes from the set).›. move('right') maps items: HTML and CSS get side: 'right', the rest unchanged → left = [JS], right = [React, Vue, HTML, CSS]. setSelected(new Set()) clears the highlight.‹. toggle('react') → {react}; move('left') flips React's side to 'left' → left = [JS, React], right = [Vue, HTML, CSS].› with nothing selected. selected is empty, so the map changes nothing — a safe no-op.Each move was a single items rewrite; the two columns followed automatically from the filters.
side field.Set of ids across both.new Set(prev) / items.map(...).setSelected(new Set()).i.id.›/‹ when no selected item is on the relevant side.The reducer keeps every update atomic, including clearing selection in the same move transition.
import { useReducer } from 'react';
import './styles.css';
type Side = 'left' | 'right';
type Item = { id: string; label: string; side: Side };
type State = { items: Item[]; selected: Set<string> };
type Action =
| { type: 'toggle'; id: string }
| { type: 'move'; side: Side };
const INITIAL: Item[] = [
{ id: 'html', label: 'HTML', side: 'left' },
{ id: 'css', label: 'CSS', side: 'left' },
{ id: 'js', label: 'JavaScript', side: 'left' },
{ id: 'react', label: 'React', side: 'right' },
{ id: 'vue', label: 'Vue', side: 'right' },
];
function reducer(state: State, action: Action): State {
if (action.type === 'toggle') {
const selected = new Set(state.selected);
if (selected.has(action.id)) selected.delete(action.id);
else selected.add(action.id);
return { ...state, selected };
}
return {
items: state.items.map((item) =>
state.selected.has(item.id) ? { ...item, side: action.side } : item,
),
selected: new Set(),
};
}
export default function App() {
const [state, dispatch] = useReducer(reducer, {
items: INITIAL,
selected: new Set<string>(),
});
function column(title: string, side: Side) {
return (
<div className="list">
<div className="list-head">{title}</div>
<ul className="list-body">
{state.items.filter((item) => item.side === side).map((item) => (
<li
key={item.id}
className={state.selected.has(item.id) ? 'item selected' : 'item'}
onClick={() => dispatch({ type: 'toggle', id: item.id })}
>
{item.label}
</li>
))}
</ul>
</div>
);
}
return (
<main className="container">
<h1>Transfer List</h1>
<div className="transfer">
{column('Available', 'left')}
<div className="controls">
<button aria-label="Move right" onClick={() => dispatch({ type: 'move', side: 'right' })}>›</button>
<button aria-label="Move left" onClick={() => dispatch({ type: 'move', side: 'left' })}>‹</button>
</div>
{column('Selected', 'right')}
</div>
</main>
);
}A custom hook owns the collection and selection while the component only renders its public model.
import { useMemo, useState } from 'react';
import './styles.css';
type Side = 'left' | 'right';
type Item = { id: string; label: string; side: Side };
const INITIAL: Item[] = [
{ id: 'html', label: 'HTML', side: 'left' },
{ id: 'css', label: 'CSS', side: 'left' },
{ id: 'js', label: 'JavaScript', side: 'left' },
{ id: 'react', label: 'React', side: 'right' },
{ id: 'vue', label: 'Vue', side: 'right' },
];
function useTransferList() {
const [items, setItems] = useState(INITIAL);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
return {
left: items.filter((item) => item.side === 'left'),
right: items.filter((item) => item.side === 'right'),
selected,
toggle(id: string) {
setSelectedIds((ids) =>
ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id],
);
},
move(side: Side) {
setItems((current) =>
current.map((item) => selected.has(item.id) ? { ...item, side } : item),
);
setSelectedIds([]);
},
};
}
export default function App() {
const transfer = useTransferList();
function column(title: string, items: Item[]) {
return (
<div className="list">
<div className="list-head">{title}</div>
<ul className="list-body">
{items.map((item) => (
<li
key={item.id}
className={transfer.selected.has(item.id) ? 'item selected' : 'item'}
onClick={() => transfer.toggle(item.id)}
>
{item.label}
</li>
))}
</ul>
</div>
);
}
return (
<main className="container">
<h1>Transfer List</h1>
<div className="transfer">
{column('Available', transfer.left)}
<div className="controls">
<button aria-label="Move right" onClick={() => transfer.move('right')}>›</button>
<button aria-label="Move left" onClick={() => transfer.move('left')}>‹</button>
</div>
{column('Selected', transfer.right)}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a transfer list: two side-by-side lists with arrow buttons that move selected items from one to the other. The clean model is a single array of items, each tagged with which side it's on, plus a Set of selected ids — the two visible lists are just filters of that array, and moving means flipping side.
type Item = { id: string; label: string; side: 'left' | 'right' };
// A self-contained component. No props.
function App(): JSX.Element;
Two lists, a › button, and a ‹ button.
select HTML + CSS on the left, click ›
→ both move to the right list; selection clears
left = items.filter(i => i.side === 'left')
right = items.filter(i => i.side === 'right')
side per item. The two lists are filters; don't keep two separate arrays in sync.Set of ids. Clicking toggles membership; highlight reflects it.side. Map the items, change side for selected ids, then clear the selection.Two lists, but one array. Each item carries which side it's on, the two columns are filters of that array, and "selected" is a Set of ids. Moving items is one map that flips side for the selected ids.
The obvious model is two arrays — left and right — and moving an item means removing it from one and pushing it into the other. That's two mutations that must stay consistent, and "what's selected" has to be tracked per list too. It's much simpler to keep all items in one array where each remembers its side. The columns become filter calls, selection is a single Set of ids spanning both lists, and a move just rewrites the side of the selected items.
State: items (each { id, label, side }) and selected (a Set<string>). left and right are items.filter(i => i.side === 'left' | 'right'). Clicking an item toggles its id in selected. The › button maps over items, setting side: 'right' for every selected id; ‹ does the same with 'left'. After a move, clear the selection.
The two-array version looks natural but multiplies the bookkeeping:
const [left, setLeft] = useState([...]);
const [right, setRight] = useState([...]);
function moveRight(item) {
setLeft(left.filter((i) => i !== item));
setRight([...right, item]); // two updates that must agree
}
Every move touches both arrays, and you need selection state for each list separately. Miss one update and an item is in both lists or neither. Folding everything into one array with a side makes a move a single map and selection a single Set.
import { useState } from 'react';
import './styles.css';
type Item = { id: string; label: string; side: 'left' | 'right' };
const INITIAL: Item[] = [
{ id: 'html', label: 'HTML', side: 'left' },
{ id: 'css', label: 'CSS', side: 'left' },
{ id: 'js', label: 'JavaScript', side: 'left' },
{ id: 'react', label: 'React', side: 'right' },
{ id: 'vue', label: 'Vue', side: 'right' },
];
export default function App() {
const [items, setItems] = useState<Item[]>(INITIAL);
const [selected, setSelected] = useState<Set<string>>(new Set());
const left = items.filter((i) => i.side === 'left');
const right = items.filter((i) => i.side === 'right');
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function move(to: 'left' | 'right') {
setItems(items.map((i) => (selected.has(i.id) ? { ...i, side: to } : i)));
setSelected(new Set());
}
function column(title: string, list: Item[]) {
return (
<div className="list">
<div className="list-head">{title}</div>
<ul className="list-body">
{list.map((i) => (
<li
key={i.id}
className={selected.has(i.id) ? 'item selected' : 'item'}
onClick={() => toggle(i.id)}
>
{i.label}
</li>
))}
</ul>
</div>
);
}
return (
<main className="container">
<h1>Transfer List</h1>
<div className="transfer">
{column('Available', left)}
<div className="controls">
<button aria-label="Move right" onClick={() => move('right')}>
›
</button>
<button aria-label="Move left" onClick={() => move('left')}>
‹
</button>
</div>
{column('Selected', right)}
</div>
</main>
);
}
left and right are derived by filter on every render, so they can't disagree with items. toggle flips an id in the selected set (copying first for a new reference). move(to) is the heart: one map sets side: to for selected ids and leaves the rest untouched, then setSelected(new Set()) clears the highlight. Selected items already on the target side simply stay there. The column helper renders either list with the same markup, applying the selected class from the shared set.
Left = [HTML, CSS, JS], right = [React, Vue], nothing selected.
toggle('html') then toggle('css') → selected = {html, css}. Both rows highlight (the class comes from the set).›. move('right') maps items: HTML and CSS get side: 'right', the rest unchanged → left = [JS], right = [React, Vue, HTML, CSS]. setSelected(new Set()) clears the highlight.‹. toggle('react') → {react}; move('left') flips React's side to 'left' → left = [JS, React], right = [Vue, HTML, CSS].› with nothing selected. selected is empty, so the map changes nothing — a safe no-op.Each move was a single items rewrite; the two columns followed automatically from the filters.
side field.Set of ids across both.new Set(prev) / items.map(...).setSelected(new Set()).i.id.›/‹ when no selected item is on the relevant side.The reducer keeps every update atomic, including clearing selection in the same move transition.
import { useReducer } from 'react';
import './styles.css';
type Side = 'left' | 'right';
type Item = { id: string; label: string; side: Side };
type State = { items: Item[]; selected: Set<string> };
type Action =
| { type: 'toggle'; id: string }
| { type: 'move'; side: Side };
const INITIAL: Item[] = [
{ id: 'html', label: 'HTML', side: 'left' },
{ id: 'css', label: 'CSS', side: 'left' },
{ id: 'js', label: 'JavaScript', side: 'left' },
{ id: 'react', label: 'React', side: 'right' },
{ id: 'vue', label: 'Vue', side: 'right' },
];
function reducer(state: State, action: Action): State {
if (action.type === 'toggle') {
const selected = new Set(state.selected);
if (selected.has(action.id)) selected.delete(action.id);
else selected.add(action.id);
return { ...state, selected };
}
return {
items: state.items.map((item) =>
state.selected.has(item.id) ? { ...item, side: action.side } : item,
),
selected: new Set(),
};
}
export default function App() {
const [state, dispatch] = useReducer(reducer, {
items: INITIAL,
selected: new Set<string>(),
});
function column(title: string, side: Side) {
return (
<div className="list">
<div className="list-head">{title}</div>
<ul className="list-body">
{state.items.filter((item) => item.side === side).map((item) => (
<li
key={item.id}
className={state.selected.has(item.id) ? 'item selected' : 'item'}
onClick={() => dispatch({ type: 'toggle', id: item.id })}
>
{item.label}
</li>
))}
</ul>
</div>
);
}
return (
<main className="container">
<h1>Transfer List</h1>
<div className="transfer">
{column('Available', 'left')}
<div className="controls">
<button aria-label="Move right" onClick={() => dispatch({ type: 'move', side: 'right' })}>›</button>
<button aria-label="Move left" onClick={() => dispatch({ type: 'move', side: 'left' })}>‹</button>
</div>
{column('Selected', 'right')}
</div>
</main>
);
}A custom hook owns the collection and selection while the component only renders its public model.
import { useMemo, useState } from 'react';
import './styles.css';
type Side = 'left' | 'right';
type Item = { id: string; label: string; side: Side };
const INITIAL: Item[] = [
{ id: 'html', label: 'HTML', side: 'left' },
{ id: 'css', label: 'CSS', side: 'left' },
{ id: 'js', label: 'JavaScript', side: 'left' },
{ id: 'react', label: 'React', side: 'right' },
{ id: 'vue', label: 'Vue', side: 'right' },
];
function useTransferList() {
const [items, setItems] = useState(INITIAL);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
return {
left: items.filter((item) => item.side === 'left'),
right: items.filter((item) => item.side === 'right'),
selected,
toggle(id: string) {
setSelectedIds((ids) =>
ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id],
);
},
move(side: Side) {
setItems((current) =>
current.map((item) => selected.has(item.id) ? { ...item, side } : item),
);
setSelectedIds([]);
},
};
}
export default function App() {
const transfer = useTransferList();
function column(title: string, items: Item[]) {
return (
<div className="list">
<div className="list-head">{title}</div>
<ul className="list-body">
{items.map((item) => (
<li
key={item.id}
className={transfer.selected.has(item.id) ? 'item selected' : 'item'}
onClick={() => transfer.toggle(item.id)}
>
{item.label}
</li>
))}
</ul>
</div>
);
}
return (
<main className="container">
<h1>Transfer List</h1>
<div className="transfer">
{column('Available', transfer.left)}
<div className="controls">
<button aria-label="Move right" onClick={() => transfer.move('right')}>›</button>
<button aria-label="Move left" onClick={() => transfer.move('left')}>‹</button>
</div>
{column('Selected', transfer.right)}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.