Why the checkmarks land on the wrong guest
What you saw
Each checkbox works perfectly in isolation. The mess only starts when the list changes shape: remove a no-show from the middle and the green "arrived" marks slide onto the wrong people. Move a guest up and their checked-in status stays behind on whoever inherits their old spot. The checkmarks behave as if they belong to a seat, not a person.
That last sentence is the whole bug. They literally do.
What's actually happening
Two facts have to be true at the same time for this to happen — and they both are here.
Fact one: the "checked in" state lives inside each row, not in the data. Look at GuestRow.jsx:
export default function GuestRow({ guest, onMoveUp, onRemove }) {
const [checkedIn, setCheckedIn] = useState(false) // local to this row
// ...
}
The guest objects in App.jsx only carry id and name. Whether someone has arrived is held in component state, one useState per rendered row.
Fact two: the rows are keyed by their position. Look at App.jsx:
{guests.map((guest, index) => (
<GuestRow key={index} guest={guest} ... /> // key is the array index
))}
Now connect them. When guests changes, React has to match the new list of rows against the old one to decide which existing components (and their state) to keep, which to create, and which to throw away. It does that matching by key. With key={index}, the keys are 0, 1, 2, 3, 4 — and they stay 0, 1, 2, 3, 4 no matter who's actually in those slots.
Walk the removal through. Start with checkboxes by position:
key 0 Ava ☑ (you checked her in)
key 1 Ben ☐
key 2 Cleo ☑ (you checked her in)
key 3 Dan ☐
Remove Ben. The new array is [Ava, Cleo, Dan], and React re-keys it 0, 1, 2:
key 0 Ava key 1 Cleo key 2 Dan
React compares by key. Key 0 still exists → keep that component and its state (checkedIn = true), now showing Ava. Fine, by luck. Key 1 still exists → keep its state too — but key 1's state was Ben's ☐. It's now rendering Cleo. So Cleo, who you checked in, shows unchecked. Key 2 also persists, carrying Cleo's old ☑, now rendering Dan — who just appeared checked in despite never arriving. The component instances never moved; the data flowing through them shifted up by one, and the local state stayed put.
React did exactly what you told it: "the row with key 1 is the same row as before, keep its state." You meant "the row for Ben." Those were the same thing only as long as nobody left.
The fix
Give each row a key that identifies the guest, not the slot. The guests already have stable ids:
{guests.map((guest) => (
<GuestRow key={guest.id} guest={guest} ... />
))}
Now the keys are g1, g3, g4... and they travel with the guest. Remove Ben (g2) and React sees g1, g3, g4 — the same keys as before minus g2 — so it keeps each guest's component (and their checkedIn) and only unmounts Ben's. Cleo stays checked, Dan stays unchecked, and reordering carries each guest's status along because the key moves with them. (You can drop the now-unused index argument entirely.)
There's a second, equally valid fix worth knowing: lift the state out of the row so identity isn't a question. Store checkedIn on the guest data —
const [guests, setGuests] = useState(
INITIAL_GUESTS.map((g) => ({ ...g, checkedIn: false }))
)
const toggle = (id) =>
setGuests((gs) => gs.map((g) => (g.id === id ? { ...g, checkedIn: !g.checkedIn } : g)))
— and the checkbox is driven by guest.checkedIn. Because the truth now lives in the array keyed by id, there's no per-slot component state to misattribute in the first place. Use a proper key regardless; lifting state is the bigger hammer for when rows hold a lot of state.
The bug class: index-as-key (mismatched list reconciliation)
This is the index-as-key bug — using an array index as a React key for a list whose items can be reordered, inserted, or removed. key is not decoration; it's the identity React uses to decide which component instance survives a re-render and which keeps its state, its focus, its scroll position, its in-progress input. Tie that identity to position and every structural change quietly hands one item's state to another.
How to spot it in the wild:
key={index}(orkey={i}) on a list that isn't strictly append-only and static.- The tell-tale symptom: list items each hold their own state — a checkbox, a text input, an expanded/collapsed panel, focus — and that state "sticks to the slot" when you add, remove, or reorder. Type into the 3rd input, delete the 1st row, and your text is now in the 2nd input.
- It's invisible until the list mutates, which is why it sails through a quick demo and surfaces in real use.
The rule that prevents it: a key must be a stable identifier of the item, not its position. Reach for a real id from your data. Index-as-key is only safe when the list is static and never reordered or filtered — and "never" has a way of not lasting. When in doubt, give your data ids and key by them.