Why the filter came up empty
What you saw
Fourteen crumbs, four per page, a difficulty filter on top. Filtering from page one worked perfectly. But page forward a few times, pick a difficulty, and the whole list vanished — "No crumbs to show" — while the tally in the corner still said there were matches. The pager even admitted something was wrong, reading "Page 3 of 1."
The filter wasn't broken. The page number was.
What's actually happening
Two pieces of state describe what's on screen, and they live side by side in App.jsx:
const [difficulty, setDifficulty] = useState('all')
const [page, setPage] = useState(1)
page only makes sense relative to the current result set. When you're on "all" (14 crumbs, 4 pages) and you've clicked Next twice, page is 3. Now you click "crusty." That updates difficulty — but nothing updates page:
onClick={() => onChange(level)} // sets difficulty, never touches page
So the render recomputes the filtered list correctly…
const filtered = CRUMBS.filter((c) => c.difficulty === 'crusty') // 3 crumbs
const totalPages = Math.ceil(3 / 4) // 1 page
const start = (page - 1) * PAGE_SIZE // (3 - 1) * 4 = 8
const visible = filtered.slice(8, 12) // [] — past the end
page is still 3, but the crusty list has only one page. The slice starts at index 8 of a 3-item array and slice quietly returns []. No crash, no warning — Array.slice past the end just hands back an empty array. That's your blank screen, and "Page 3 of 1" is the same stale page leaking into the label.
The reason it hides so well: the developer almost certainly tested the filter from page one, where page is already 1 and the bug can't fire. It only appears once a user has paged in first — which real users do constantly and demos never do.
The fix
The current page is derived from the result set, so whenever the result set changes, the page has to be reconciled. The cleanest answer for a filter is to send the user back to page one whenever the filter changes — reset page in the same handler that changes difficulty:
function changeFilter(level) {
setDifficulty(level)
setPage(1)
}
// ...
<FilterBar active={difficulty} onChange={changeFilter} />
Now picking any difficulty starts you at the top of its results, and there's no way to be stranded on a page that no longer exists.
A second, complementary safety net is to clamp the page to the valid range when you compute the slice, so an out-of-range page can never produce an empty view even if something else sets it:
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, totalPages)
const start = (safePage - 1) * PAGE_SIZE
Reset-to-one fixes the UX (you almost always want to see the new results from the start); clamping fixes the invariant (page can never point past the end). Doing both is belt-and-suspenders and entirely reasonable.
The bug class: dependent state that didn't get reset
This is a stale dependent-state bug. One piece of state (page) is only meaningful in the context of another (the filtered list), and when the context changed, the dependent value wasn't brought back into a valid range. The filter and the page silently disagreed, and the disagreement only surfaced at a boundary — an empty slice.
It's one of the most common bugs in real list UIs, because every list eventually grows a filter, a search box, or a sort control bolted on next to its pagination:
- Filter + pagination — exactly this. Change the filter while deep in the pages and you fall off the end.
- Search + pagination — type a new query on page 5, get zero results even though the query matches plenty.
- Sort + selection — re-sort a table and the "selected row index" now points at a different row.
- Tabs + scroll position — switch tabs and the old scroll offset makes the new tab look empty.
How to recognize it in the wild:
- Results are empty when they obviously shouldn't be, and a count or badge elsewhere disagrees with the empty list.
- A "page X of Y" (or "step X of Y") indicator shows X greater than Y — a dead giveaway that two bits of state are out of sync.
- The bug only reproduces when you do something before the action that breaks — "works on a fresh load, breaks if you navigate first." That ordering dependency is the signature of leftover state.
The mental model: whenever you change what a list contains — filter, search, sort — anything that indexes into that list (the page, the selection, the scroll) has to be reset or re-validated in the same breath. Pagination state isn't independent; it's downstream of the result set, and downstream state has to follow when the source changes.