Клиентское и серверное состояние — Примеры

Пример 1 — Три флага, которые могут быть истинными одновременно

Загрузку, ошибку и данные экран со списком отслеживает по отдельности. Медленная повторная попытка — и на миг экран показывает сразу спиннер, ошибку и предыдущие строки. Скажите, что допускает тип, а затем смоделируйте запрос одним значением.

let loading = false;
let error: Error | null = null;
let data: Item[] = [];

// во время неудачного обновления: loading true, ошибка задана, data всё ещё заполнена

Решение

type Query<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "ready"; data: T; stale: boolean };

function render(query: Query<Item[]>) {
  switch (query.status) {
    case "ready": return listItems(query.data, query.stale);
    case "error": return showError(query.error);
    case "loading": return spinner();
    case "idle": return null;
  }
}

Восемь комбинаций описывают три независимые переменные, и несколько из них бессмысленны: «загружается, содержит ошибку и держит данные» — не состояние, в котором может находиться запрос, однако именно это дают флаги, когда расходятся. Размеченное объединение хранит один факт, поэтому невозможные комбинации нельзя записать, а рендерер не может прочитать data вне ветви, где она существует. Флаг stale в состоянии ready — та деталь, которую стоит заметить: фоновое обновление не возвращает в loading, поэтому предыдущее значение остаётся видимым, и интерфейс говорит, что обновляется, а не пустеет до спиннера.

Пример 2 — Инвалидация перезагрузкой всего

Удаление заново запрашивает каждый список на экране, поэтому страница мерцает, а четыре запроса возвращают одни и те же данные. Скажите, что было нужно кэшу вместо этого, и обновите затронутую запись.

async function deleteOrder(id: string) {
  await fetch(`/api/orders/${id}`, { method: "DELETE" });
  await refetchOrders();
  await refetchCustomers();
  await refetchDashboard();
}

Решение

async function deleteOrder(id: string) {
  const response = await fetch(`/api/orders/${id}`, { method: "DELETE" });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  cache.remove("orders:page=1", (order) => order.id === id);
  cache.markStale("dashboard:summary");     // обновляется в фоне
}

После записи вопрос стоит не «перезагрузить», а «какие записи кэша затронула эта запись», ведь серверные данные — кэш, а не источник правды; назвать эти ключи и есть вся работа. Удаление строки из списка, которому она принадлежала, делает изменение мгновенно видимым без сетевого обхода. Пометка же производной сводки устаревшей позволяет ей обновиться за предыдущим значением, а не стереть его. Точная инвалидация вообще возможна благодаря ключу каждой записи по её входным данным; кэш без ключей и без срока годности — баг с длинным запалом.