Seat Booking Grid
by Pratik Rai
Read the full write-upBuild the seat picker from a cinema booking page.
Seats are laid out in rows at different prices. Some are already sold. A customer picks up to four and sees a running total.
Requirements
Render the rows from
ROWS. Anxin the layout is a sold seat and cannot be picked.Clicking an available seat selects it; clicking it again deselects it.
At most
MAX_SEATScan be selected. Attempting more shows a message and changes nothing.Deselecting always works, even when at the limit.
Show which seats are picked and the total price, using the price of each seat’s row.
Seats carry
aria-pressedand a label that includes the seat, its price and whether it is sold.
Notes
Prices differ per row, so the total is not just a count times one number.
List the picked seats in seat order rather than the order they were clicked.
Hints
Seat Booking Grid (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useMemo } from 'react';
2
3// Rows of seats. 'sold' seats are already taken and cannot be picked.
4const ROWS = [
5 { row: 'A', price: 250, seats: 'oooxxooo' },
6 { row: 'B', price: 250, seats: 'ooooooox' },
7 { row: 'C', price: 180, seats: 'xoooooox' },
8 { row: 'D', price: 180, seats: 'oooooooo' },
9 { row: 'E', price: 120, seats: 'ooxxoooo' },
10];
11
12const MAX_SEATS = 4;
13
14// Price is a property of the row, so a lookup keyed by seat id means the
15// summary never has to search the layout again.
16const PRICE_BY_SEAT = {};
17ROWS.forEach(({ row, price, seats }) => {
18 seats.split('').forEach((_, index) => {
19 PRICE_BY_SEAT[row + (index + 1)] = price;
20 });
21});
22
23export default function App() {
24 // A Set of seat ids. Membership is the only question ever asked of it, and
25 // ids are stable, so this beats an array of seat objects.
26 const [picked, setPicked] = useState(() => new Set());
27 const [notice, setNotice] = useState('');
28
29 const toggle = (id) => {
30 // The branch is decided out here rather than inside the updater. A state
31 // updater has to be pure — setting another piece of state from inside one
32 // runs twice under StrictMode and is a side effect where React expects a
33 // calculation.
34 if (picked.has(id)) {
35 const next = new Set(picked);
36 next.delete(id);
37 setPicked(next);
38 setNotice('');
39 return;
40 }
41
42 // Deselecting is always allowed; only adding is capped, or somebody at the
43 // limit could never change their mind.
44 if (picked.size >= MAX_SEATS) {
45 setNotice('You can pick at most ' + MAX_SEATS + ' seats.');
46 return;
47 }
48
49 const next = new Set(picked);
50 next.add(id);
51 setPicked(next);
52 setNotice('');
53 };
54
55 const total = useMemo(
56 () => [...picked].reduce((sum, id) => sum + PRICE_BY_SEAT[id], 0),
57 [picked]
58 );
59
60 // Sorted so the summary reads in seat order rather than click order.
61 const pickedList = [...picked].sort();
62
63 return (
64 <div className="hall">
65 <h1>Pick your seats</h1>
66
67 <p className="screen-label">Screen</p>
68 <div className="screen" />
69
70 {ROWS.map(({ row, price, seats }) => (
71 <div className="row" key={row}>
72 <span className="row-label">{row} · ₹{price}</span>
73 {seats.split('').map((state, index) => {
74 const id = row + (index + 1);
75 const sold = state === 'x';
76 const isPicked = picked.has(id);
77 return (
78 <button
79 className={
80 'seat' + (sold ? ' seat-sold' : '') + (isPicked ? ' seat-picked' : '')
81 }
82 key={id}
83 disabled={sold}
84 onClick={() => toggle(id)}
85 aria-pressed={isPicked}
86 aria-label={
87 'Seat ' + id + ', ₹' + price + (sold ? ', sold' : isPicked ? ', selected' : '')
88 }
89 >
90 {index + 1}
91 </button>
92 );
93 })}
94 </div>
95 ))}
96
97 <div className="summary">
98 {pickedList.length === 0 ? (
99 <span className="picked">No seats picked yet.</span>
100 ) : (
101 <>
102 <span className="picked">
103 {pickedList.length} seat{pickedList.length > 1 ? 's' : ''}: {pickedList.join(', ')}
104 </span>
105 <br />
106 Total <strong>₹{total}</strong>
107 </>
108 )}
109 {notice && <p className="notice">{notice}</p>}
110 </div>
111
112 <div className="legend">
113 <span><span className="swatch" style={{ background: '#ffffff', border: '1px solid #d4d4d8' }} />Available</span>
114 <span><span className="swatch" style={{ background: '#16a34a' }} />Selected</span>
115 <span><span className="swatch" style={{ background: '#e4e4e7' }} />Sold</span>
116 </div>
117 </div>
118 );
119}How it works
This is the rare machine-coding problem where layout and logic carry equal weight, which is why it gets used — a candidate who can only do one shows it quickly.
On the state: a Set of seat ids rather than an array of seat objects. Every question the UI asks is membership, ids are stable and derivable, and the summary needs nothing the id cannot look up. Copying the Set before mutating is the same rule as anywhere else in React — add returns the same object, so returning it re-renders nothing.
The limit has an asymmetry worth catching. Guarding the whole toggle means someone who has picked four seats cannot deselect any of them, which is a genuinely broken UI. Only the add path is capped.
Prices living on the row means the total cannot be a count multiplied by a price. Flattening to a seat-id lookup once, at module scope, keeps the summary a reduce instead of a nested search — and if prices later came from an API, that lookup is the one thing that would need to move.
Sorting the picked list matters more than it sounds: seats listed in click order look like a bug to anyone reading their own booking back.