FSD Frontend System Design

Part V β€” The Craft Β· Lesson 10.12

Pagination Part-2

In one lineBuild it: URL-driven state, an ellipsis page range, and a keyboard-accessible nav element.

Where you've seen itAny search results footer: ← 1 … 4 [5] 6 … 92 β†’.

  • Time17 min
  • DiagramComponent structure + the ellipsis window algorithm
  • Chapter10 Β· Low Level Design

Diagram

diagram
COMPONENT STRUCTURE β€” page state lives in the URL

  URL  ?page=5&sort=price&q=shoes
        β”‚  useSearchParams()
        β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ usePagination(total, page, size)   β”‚  pure: returns the range
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ <Pagination>                       β”‚
  β”‚   ← Prev  1 … 4 [5] 6 … 92  Next β†’ β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β–Ό setSearchParams({page: 6})
  URL changes ──► query refetches (6.9) ──► table re-renders

  Never store the page in component state β€” the URL is the state.
diagram
THE ELLIPSIS WINDOW β€” always show the same number of slots

  total 92, sibling count 1, boundaries 1

  page 1    [1] 2 3 … 92
  page 5     1 … 4 [5] 6 … 92
  page 50    1 … 49 [50] 51 … 92
  page 92    1 … 90 91 [92]

  Rule: first + last always visible Β· window of (siblings) around
        current Β· ellipsis fills the gaps Β· fixed width so the
        control never jumps as pages change (CLS β€” 5.1)
diagram
STATES TO HANDLE

  loading     keep the OLD rows visible + dim them
              (blanking the table causes a jarring layout jump)
  empty       "No results for X" + a way to clear filters
  error       keep the pager usable, offer retry
  single page hide the control entirely
  invalid     ?page=999 β†’ clamp to last page, don't crash

How it works

  1. Read page and size from the URL, so refresh, back, and shared links all reproduce the exact view.
  2. Compute the range in a pure hook β€” easy to unit test with no rendering (4.2).
  3. Render a <nav aria-label="Pagination"> containing real <a> or <button> elements; mark the current one with aria-current="page".
  4. Keep placeholders stable so the control doesn't resize between pages.
  5. Prefetch the next page on hover and preserve scroll position when navigating.
js
function usePagination({ total, page, siblings = 1, boundaries = 1 }) {
  const pages = Math.ceil(total / size);
  if (pages <= 5 + siblings * 2) return range(1, pages);        // no ellipsis
  const left  = Math.max(page - siblings, boundaries + 2);
  const right = Math.min(page + siblings, pages - boundaries - 1);
  return [
    ...range(1, boundaries),
    ...(left > boundaries + 1 ? ['…'] : []),
    ...range(left, right),
    ...(right < pages - boundaries ? ['…'] : []),
    ...range(pages - boundaries + 1, pages),
  ];
}

Trade-offs

DecisionConsequence
Page in URLShareable, back-button correct
Page in component stateBreaks refresh and sharing
<a href> linksCrawlable, middle-clickable
<button> onlySPA-only; loses SEO and open-in-new-tab
Keep old rows while loadingNo layout jump, feels faster

Interview angle

"Build a pagination component."

  • Start with the API: total, page, pageSize, onChange β€” and state that page lives in the URL, not in the component.
  • Extract the ellipsis range into a pure function so it's unit-testable, and keep the slot count fixed to avoid layout shift.
  • Use <nav> with aria-current="page" and real links, so keyboard, screen reader, and middle-click all behave correctly.

Recap

  • URL is the source of truth for page, size, sort, and filters.
  • A pure range function keeps the ellipsis logic testable and the control stable.
  • Dim old rows instead of blanking the table while the next page loads.