# Magic: The Gathering example (/docs/data-table/magic)





Everything on the [main DataTable page](/docs/data-table) runs against small, in-memory arrays.
This page is the opposite: every row below comes from a live network request to
[Scryfall](https://scryfall.com/docs/syntax) (`api.scryfall.com`), a free, open Magic: The
Gathering card database — so the loading, error, sorting, filtering, and search you see are the
real thing, not a mock. Sorting, filters, and the search box also sync to this page's own URL —
try changing a filter, then reload.

<Button>
  <Link href="/examples/data-table">
    Full page example
  </Link>
</Button>

***

<div className="not-prose">
  <MagicDataTable />
</div>

## What's server-driven [#whats-server-driven]

Scryfall genuinely sorts, filters, and searches server-side — verified directly against the live
API, not assumed from its docs:

* **Search** (`enableGlobalFilter`, debounced) is appended as a bare term to Scryfall's own `q`
  search syntax, which matches on name (and oracle text).
* **Column filters** are real search-syntax clauses, not client-side narrowing of an already-loaded
  page: the type-line filter maps to `t:"…"`, the multi-select colors filter to an OR-grouped
  `(c:w or c:u)`, rarity to `r:<value>`, and the mana-value/price number filters to `cmc>=3` /
  `usd<=5` — Scryfall's numeric fields take a comparison operator with **no colon** (confirmed live:
  `cmc:>=3`, combining both, is invalid and silently ignored with a warning). The released-date
  filter is Scryfall's `date`/`year` keyword the same way, always sending both bounds
  (`date>=2015-01-01 date<=2020-12-31`).
* **Sorting** covers seven columns for real — `name`, `colors` (→ `order=color`), `rarity`, mana
  value, `set`, release date, and price all genuinely reorder results server-side, a real step up
  from the kit's earlier Pokémon example, where only `name` did. `enableMultiSort` is deliberately
  off: Scryfall's `order`/`dir` only accept a single field, so a multi-sort UI would silently drop
  every secondary key.
* **Pagination** has a real, known total — Scryfall's search response includes `total_cards`, wired
  straight into `rowCount` for a genuine "X–Y of Z" footer and last-page jump. The one wrinkle:
  Scryfall's page size is a fixed 175 rows, not configurable, so `pageSizeOptions` offers exactly
  that one real value instead of a picker implying a choice that doesn't exist.

## One request, not two [#one-request-not-two]

Unlike the kit's earlier Pokémon example — whose list endpoint only returned brief rows, requiring
a second per-row detail fetch to hydrate every filterable field — Scryfall's search response
already returns the **full** card object: mana cost, oracle text, prices, legalities, and every
image size. Loading a page is a single `useQuery` call, and the row-detail Drawer needs no fetch of
its own either.

## Caching and validation [#caching-and-validation]

Data fetching runs through `@tanstack/react-query` (`useQuery` + `keepPreviousData`) instead of
hand-rolled `useEffect` + loading/error state — the same recipe the
[Query integration](/docs/data-table#query-integration) section on the main page documents in a
fenced code block. Keeping the previous page's rows around while the next page loads is also what
makes `DataTable`'s own `loading` prop (bound to `isFetching`) correctly dim the existing rows
instead of resetting to a skeleton.

The card shape is a single zod schema (`CardSchema`), not a hand-written type — `columnHelperFor`
derives fully-typed columns straight from it, and every page's response is `.parse()`d against it
before the query resolves. That's real runtime validation of a third-party API's response, not just
type inference: if Scryfall's shape ever drifts, it surfaces as a normal `DataTable` error state
instead of a silent crash. One real API nuance this schema (and its column cells) accounts for:
double-faced cards omit `image_uris`/`mana_cost`/`colors` at the top level entirely — those only
exist per-face on `card_faces[]` — while split cards keep them at the top level. Falling back to
`card_faces[0]` when the top-level field is missing covers both shapes.

## URL-synced state [#url-synced-state]

Sorting, column filters, and the global search box all sync to this page's own URL query string —
mirroring the one other URL-synced view in this app, the
[changelog timeline](/docs/changelog)'s `apps/docs/lib/changelog-url.ts` codec +
`useSearchParams`/`router.replace` component. A pure `magic-data-table-url.ts` codec encodes/decodes
the table's state, validating every token on the way in so a hand-edited or stale URL can never
break rendering. Pagination is deliberately excluded — Scryfall's fixed page size isn't meaningful
to bookmark against.

## Row details [#row-details]

Clicking a row (or its trailing view button — a row click is a convenience, never the only way to
act on a row) opens a `Drawer` with the card's full detail, instantly, including rules text,
prices in three currencies, and legality across seven common formats. `enableColumnVisibility` and
`responsive="stack"` are both on, so narrowing your viewport (or the window) switches to the
stacked-card layout and its "Table options" drawer — driven by the exact same live data. Column
widths persist across reloads via `useLocalStorage`, and selecting rows enables an "Export
decklist" action that builds a plain-text decklist from the current selection.

Source: `apps/docs/stories/magic-data-table.tsx`.
