跳转到主要内容

Data Fetching

This guide focuses on reliably getting data to the UI. It avoids locking you into a single library and instead gives a decision framework.

Define the problem

“Fetch data” usually includes caching, consistency, concurrency, retries, and UX—not just one request.

So the goal is not “how to call fetch”, but how to build a data pipeline that stays correct under latency, failures, and repeated navigation.

Recommended approach

Start with a clear ownership model: where requests happen, where state is stored, and how the UI consumes it.

  • Place fetching near the “data boundary” (route/server/data layer). UI components should consume results.
  • Use stable request keys to control caching, invalidation, and prefetching.
  • Make errors visible and actionable: retry, fallback, and graceful empty states.

Once you agree on these foundations, the rest becomes “just policy”: staleness, retries, and UX states.

A decision framework

Before choosing a library or pattern, answer these questions. Most “data fetching problems” are really about boundaries, staleness, and UX.

  • Where should it run: server, client, or both? (SEO, secrets, latency, bundle size)
  • When should it run: on route entry, on interaction, or in the background?
  • How fresh must it be: strict consistency, “good enough”, or eventually consistent?
  • What is the failure mode: retry, fallback, cached stale data, or block the UI?

If you can answer these four questions for a feature, you can pick an implementation with confidence—and change it later without rewriting the UI.

Request keys: cache, dedupe, invalidate

A stable request key is the foundation for anything beyond “fire a request in an Effect”. It lets you dedupe in-flight requests, cache results, and invalidate precisely.

A good key should be deterministic, include all inputs that affect the result, and be easy to invalidate by prefix.

TypeScript
type CacheEntry<T> = { data: T; updatedAt: number };

const cache = new Map<string, CacheEntry<any>>();
const inflight = new Map<string, Promise<any>>();

export async function fetchJsonCached<T>(
  key: string,
  url: string,
  options: { ttlMs?: number; signal?: AbortSignal } = {}
) {
  const ttlMs = options.ttlMs ?? 30_000;
  const hit = cache.get(key) as CacheEntry<T> | undefined;
  const now = Date.now();

  if (hit && now - hit.updatedAt < ttlMs) return hit.data;
  const existing = inflight.get(key) as Promise<T> | undefined;
  if (existing) return existing;

  const p = (async () => {
    const res = await fetch(url, { signal: options.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = (await res.json()) as T;
    cache.set(key, { data, updatedAt: Date.now() });
    return data;
  })();

  inflight.set(key, p);
  try {
    return await p;
  } finally {
    inflight.delete(key);
  }
}

export function invalidate(keyPrefix: string) {
  for (const k of cache.keys()) {
    if (k.startsWith(keyPrefix)) cache.delete(k);
  }
}

Staleness strategy

Staleness is a product decision. Different screens can—and should—use different staleness policies.

  • Strict freshness: block on every navigation/interaction; use retries and error boundaries.
  • Stale-while-revalidate: show cached data immediately, refresh in background, then update UI.
  • Eventually consistent: accept drift, make UI explicit (e.g. “Last updated 2m ago”).

Retries and backoff (minimal)

Retries should be deliberate: only retry idempotent operations, respect AbortSignal, and add backoff to avoid thundering herds.

TypeScript
export async function retry<T>(
  run: () => Promise<T>,
  options: { retries?: number; baseDelayMs?: number; signal?: AbortSignal } = {}
) {
  const retries = options.retries ?? 2;
  const baseDelayMs = options.baseDelayMs ?? 200;
  for (let attempt = 0; attempt <= retries; attempt += 1) {
    if (options.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
    try {
      return await run();
    } catch (e) {
      if (attempt === retries) throw e;
      const delay = baseDelayMs * Math.pow(2, attempt);
      await new Promise<void>((resolve, reject) => {
        const id = setTimeout(resolve, delay);
        options.signal?.addEventListener(
          'abort',
          () => {
            clearTimeout(id);
            reject(new DOMException('Aborted', 'AbortError'));
          },
          { once: true }
        );
      });
    }
  }
  throw new Error('unreachable');
}

Common pitfalls

  • Fetching inside an Effect without cancellation/race handling, letting stale responses overwrite fresh UI.
  • Treating caching as ad-hoc component state, causing repeated fetches and inconsistent invalidation.

A good rule: if you see duplicated “loading/error state + fetch + cache” logic across components, extract a shared data layer or hook.

Example pattern

A minimal pattern: track status, handle abort, and ignore stale results.

TypeScript
import { useEffect, useState } from 'react';

type State<T> =
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T };

export function useJson<T>(url: string) {
  const [state, setState] = useState<State<T>>({ status: 'loading' });

  useEffect(() => {
    let isStale = false;
    const controller = new AbortController();

    async function run() {
      setState({ status: 'loading' });
      try {
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = (await res.json()) as T;
        if (!isStale) setState({ status: 'success', data });
      } catch (e) {
        if (controller.signal.aborted) return;
        if (!isStale) setState({ status: 'error', error: e as Error });
      }
    }

    run();
    return () => {
      isStale = true;
      controller.abort();
    };
  }, [url]);

  return state;
}

Production checklist

  • Every fetch has an owner: route boundary or a dedicated data layer—not scattered across leaf components.
  • Every screen models `loading`/`error`/`empty`, with retry or recovery actions.
  • Caching has a `key` strategy and invalidation strategy (when and what to `invalidate`).
  • Requests are observable: log request id, duration, status code, and user-visible failures.

Further reading

Data Fetching - Guides - React Docs - React 文档