# Example: An explicit dataState lifecycle over a real endpoint

A customer search against /api/docs/rows, with its real 500ms delay, so loading, stale, idle and error are the actual phases rather than a description of them — search for "fail" to reach the error phase, and the rows already on screen stay sortable through it.

Source: https://pretable.ai/examples/data-state-lifecycle.md

```tsx DataStateGrid.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

import { PretableSurface, type PretableDataState } from "@pretable/react";

import { columns } from "./columns";
import { type Order, searchOrders } from "./search-orders";

const VIEWPORT_HEIGHT = 320;

export function DataStateGrid() {
  const [query, setQuery] = useState("");
  const [rows, setRows] = useState<Order[]>([]);
  const [total, setTotal] = useState(0);
  // The query the rows on screen answer, not the one being typed. It changes
  // when the result changes and at no other moment — that is the whole job of
  // a dataset key.
  const [datasetKey, setDatasetKey] = useState("");
  const [dataState, setDataState] = useState<PretableDataState>({
    phase: "loading",
  });

  // Whether a result has ever committed — the first search is `loading`
  // (nothing to show yet); every search after that is `stale` (the PREVIOUS
  // result stays on screen while the new one loads).
  const hasCommitted = useRef(false);

  const runSearch = useCallback((q: string) => {
    setDataState({ phase: hasCommitted.current ? "stale" : "loading" });
    searchOrders(q).then(
      (result) => {
        hasCommitted.current = true;
        setRows(result.rows);
        setTotal(result.total);
        setDatasetKey(q);
        setDataState({ phase: "idle" });
      },
      (error: unknown) => {
        hasCommitted.current = true;
        // Rows are deliberately left untouched — an error never discards the
        // last fulfilled result. The surface reads bodyRowCount to decide
        // between the full-viewport error block and this error strip. The
        // dataset key is left alone too: the rows still answer the query it
        // names.
        setDataState({
          phase: "error",
          message: error instanceof Error ? error.message : "Search failed",
        });
      },
    );
  }, []);

  // First load, on mount.
  useEffect(() => {
    runSearch("");
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div>
      <form
        onSubmit={(event) => {
          event.preventDefault();
          runSearch(query);
        }}
        style={{ display: "flex", gap: 8, marginBottom: 8 }}
      >
        <input
          aria-label="Search orders by customer"
          onChange={(event) => setQuery(event.target.value)}
          placeholder='Try "calder", or "fail" to see the error phase'
          style={{ flex: 1, fontSize: 13 }}
          value={query}
        />
        <button type="submit">Search</button>
      </form>
      <p role="status" style={{ margin: "0 0 8px", fontSize: 13 }}>
        Phase: <code>{dataState.phase}</code>
        {dataState.phase === "error" ? ` — ${dataState.message}` : ""}. Rows
        stay clickable and sortable throughout — try the column header, even
        during an error.
      </p>
      <PretableSurface<Order>
        ariaLabel="Order search results"
        columns={columns}
        dataState={dataState}
        getRowId={(row) => row.id}
        // The server does the filtering; the header sort stays local, which is
        // what makes it something you can still use while a request is failing.
        processing={{ filter: "external" }}
        resultMeta={{
          total: { kind: "exact", count: total },
          datasetKey,
        }}
        rows={rows}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import { numberFormats, type PretableColumn } from "@pretable/react";

import type { Order } from "./search-orders";

/**
 * Every `type` agrees with what the endpoint believes the column is — its
 * `DOCS_COLUMN_TYPES` map — because a filter operator outside a column's type
 * comes back as a 500, not as an unfiltered grid.
 *
 * `filterable: false` throughout: the only query this example sends is the
 * search box above the grid, so a funnel here would publish a filter nothing
 * forwards. Wiring the funnels to the server is the `query` / `onQueryChange`
 * pair, which /docs/server-data/query-ownership covers.
 */
export const columns: PretableColumn<Order>[] = [
  { id: "id", header: "Order", filterable: false, widthPx: 100 },
  { id: "customer", header: "Customer", filterable: false, widthPx: 160 },
  { id: "region", header: "Region", type: "enum", filterable: false },
  { id: "status", header: "Status", type: "enum", filterable: false },
  {
    id: "total",
    header: "Total",
    type: "number",
    filterable: false,
    numberFormat: numberFormats.money({ currency: "USD" }),
  },
  { id: "placedAt", header: "Placed", type: "date", filterable: false },
];
```

```ts search-orders.ts
export interface Order {
  id: string;
  customer: string;
  region: string;
  status: string;
  total: number;
  placedAt: string;
}

export interface SearchResult {
  rows: Order[];
  total: number;
}

/**
 * One POST per search, against the docs' own endpoint: a real 500ms delay so
 * `stale` is visible, and a deterministic failure — any query containing
 * "fail" — so the `error` phase is reachable without waiting on network flake.
 */
export async function searchOrders(query: string): Promise<SearchResult> {
  const response = await fetch("/api/docs/rows", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      query: {
        filters: query.trim()
          ? [{ columnId: "customer", operator: "contains", value: query }]
          : [],
        sort: [],
        rowGroups: [],
      },
      totalKind: "exact",
    }),
  });

  if (!response.ok) throw new Error("Search service unavailable");

  const body = (await response.json()) as {
    rows: Order[];
    total: { kind: string; count?: number };
  };

  return { rows: body.rows, total: body.total.count ?? body.rows.length };
}
```
