React NotesRohit’s interview study guide
Chapter 09

Data Fetching & Server State

Getting data from a server into components: by hand with effects, then with TanStack Query 5 (queries, keys, caching, mutations, optimistic updates, infinite lists), React 19's use() with Suspense, and live data over WebSockets and Server-Sent Events.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Fetching in an effect

The most basic way to load data in React is to start a request in useEffect after the component renders, keep the result in state, and render one of three things: a loading message, an error, or the data. Every React developer has written this, and interviewers like it because the naive version has three bugs hiding in it.

The mental model: an effect is a subscription to "the props this render used". When userId changes, the old subscription (the old request) must be torn down and a new one started. If you forget the teardown, the old request is still out there, and whichever response arrives last wins, even if it's for the user you navigated away from. That's the race condition.

UserProfile.jsx
import { useEffect, useState } from "react";

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setIsLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setUser(data);
        setIsLoading(false);
      })
      .catch((err) => {
        if (err.name === "AbortError") return; // we cancelled it ourselves
        setError(err);
        setIsLoading(false);
      });

    return () => controller.abort();
  }, [userId]);

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p role="alert">Error: {error.message}</p>;
  return <h2>{user.name}</h2>;
}
What’s happening
  1. First render with userId = 1: isLoading starts as true, so the component returns <p>Loading…</p>. Effects run after the browser has the DOM, so the request hasn't started yet when the loading text first appears.
  2. The effect runs: it creates an AbortController, resets isLoading/error (they matter on the second id, not the first), and calls fetch("/api/users/1", { signal }). The signal ties this request to this controller.
  3. if (!res.ok) throw … is there because fetch only rejects on network failure. A 404 or 500 resolves normally with res.ok === false. Without this line a 404 body would be stored as the "user". In the test, a 404 renders Error: HTTP 404.
  4. Now the parent re-renders with userId = 2 before user 1 has arrived (in the test, user 1 takes 100 ms and user 2 takes 10 ms). React runs the cleanup from the previous effect first: controller.abort() for request 1. Its promise rejects with an AbortError, which the catch ignores. Then the new effect starts request 2.
  5. Request 2 resolves → setUser({ name: "Grace" }). Request 1 never calls setUser, so 150 ms later the heading still says "Grace". The test also checks the signals: request 1 aborted: true, request 2 aborted: false.
  6. If you deleted the cleanup, both requests would complete and the slower one (user 1, "Ada") would land last and overwrite "Grace". The screen would show the wrong user for the URL you're on.

In development with <StrictMode>, React mounts, unmounts and re-mounts every component once to flush out missing cleanups. With this component, the test records two requests: the first is aborted by the cleanup (aborted: true) and the second one does the work (aborted: false). That's expected and harmless; see Strict Mode runs effects twice. If you see two completed requests in Strict Mode, your cleanup is missing.

AdvancedThe ignore flag, for things you can't abort

Not every async API takes an AbortSignal (a third-party SDK, a setTimeout chain). The older pattern, still common in codebases and in the React docs, is a local boolean that the cleanup flips:

React
useEffect(() => {
  let ignore = false;
  fetchUserFromSdk(userId).then((data) => {
    if (!ignore) setUser(data);
  });
  return () => {
    ignore = true;
  };
}, [userId]);
What’s happening
  1. Each effect run gets its own ignore variable, captured by its own .then callback (a closure).
  2. When userId changes, the old effect's cleanup sets its ignore to true. The new run has a fresh ignore = false.
  3. The stale response still arrives, but its callback sees ignore === true and drops the result.
  4. The difference from AbortController: the request still runs to completion and uses bandwidth. Aborting actually cancels it. Use abort when you can, the flag when you can't.

Where you'll see this: small apps, one-off widgets, and older code (often wrapped in a useFetch hook; see useFetch: a data-fetching hook). In my notes, the Redux version of the same idea was useEffect(() => { dispatch(fetchUsers()); }, [dispatch]) with loading/error/users read through useSelector: same shape, with the state moved into the store.

What the hand-written version still doesn't do: caching (go back to user 1 and it refetches), deduplication (two components = two requests), retries, refetching when data goes stale or the tab regains focus, pagination, and optimistic updates. Each is a few dozen lines, and they interact. That's the gap libraries like TanStack Query fill, and the reason the React docs recommend a framework or a data library over raw effects for anything non-trivial.

#Server state vs client state

Added

This is the idea that makes data libraries click, and it's a favourite "why would you use React Query?" interview answer.

Client state is owned by the browser: is the modal open, what's typed in the search box, which tab is selected. It's synchronous, always up to date, and only your code changes it. useState, useReducer, Context and Redux are built for it.

Server state is a cached copy of data that lives somewhere else. You don't own it: other users and other tabs change it behind your back. It's asynchronous to get, it can be out of date the moment you receive it, and several components usually want the same piece. The questions you have to answer are cache questions: when is my copy too old? Who else is already fetching it? When do I throw it away? After I change it, which copies are now wrong?

A mental model: client state is your notebook; server state is a photocopy of a page from a shared library book. Photocopies go out of date, several people want the same page, and you don't want to walk to the library every time someone asks.

CurrentUser.jsx
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";

// Version 1: every component that needs the user fetches it itself.
function useCurrentUserEffect() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch("/api/me")
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) setUser(data);
      });
    return () => {
      ignore = true;
    };
  }, []);
  return user;
}

// Version 2: the user lives in a shared cache, keyed by ["me"].
function useCurrentUserQuery() {
  const { data } = useQuery({
    queryKey: ["me"],
    queryFn: () => fetch("/api/me").then((res) => res.json()),
  });
  return data ?? null;
}

function Header() {
  const user = useCurrentUser(); // one of the two hooks above
  return <header>{user ? `Hi, ${user.name}` : "…"}</header>;
}
function Sidebar() {
  const user = useCurrentUser();
  return <aside>{user ? `${user.unread} unread` : "…"}</aside>;
}
What’s happening
  1. With version 1, Header and Sidebar each own a private copy of the user in their own useState. Each one's effect fires its own fetch("/api/me"). The test counts 2 requests for one page.
  2. The copies are independent: if Header refetched after a rename, Sidebar would keep showing the old data. Nothing connects them.
  3. With version 2, both components ask the QueryClient cache for the entry under the key ["me"]. The first useQuery to mount starts the fetch; the second finds a fetch already in flight for the same key and subscribes to it. The test counts 1 request, and both components render from the same object.
  4. When that cache entry changes (a refetch, a setQueryData), every subscribed component re-renders. That's deduplication and consistency for free.
  5. This is the point of the topic: server data isn't really "state you own" but a cache you keep in sync. Putting it in useState (or Redux) means rebuilding a cache by hand.
Client stateServer state
LivesIn the browserOn a server; you hold a copy
OwnerYour codeShared with other users and tabs
AccessSynchronousAsync, can fail
Goes stale?NoYes, all the time
Typical toolsuseState, useReducer, Context, Redux, ZustandTanStack Query, RTK Query, SWR, Apollo, framework loaders
Hard partsStructure, updatesCaching, deduping, invalidation, retries, refetching

In practice: keep server data in a query library and client state in React state (or a small store). Once server data moves out, most apps find their Redux store shrinks to a handful of UI flags. Redux Toolkit's answer to the same problem is RTK Query.

#TanStack Query: useQuery

TanStack Query (called React Query before v4) is a server-state cache. You give it a query key (the cache entry's name) and a query function (how to fetch it); it gives back the data plus a set of status flags, and handles caching, deduplication, background refetching and retries.

You set it up once: create a QueryClient (the cache) and provide it at the top of the tree. Then any component can call useQuery. This is my notes' example, moved from Axios to fetch (Axios isn't installed in the sandbox) with v5's status names.

main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TodoList } from "./TodoList";

const queryClient = new QueryClient(); // one per app, created outside components

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TodoList />
    </QueryClientProvider>
  );
}
TodoList.jsx
import { useQuery } from "@tanstack/react-query";

async function fetchTodos() {
  const res = await fetch("/api/todos");
  if (!res.ok) throw new Error(`Failed to load todos (HTTP ${res.status})`);
  return res.json();
}

export function TodoList() {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ["todos"], // the cache entry's name
    queryFn: fetchTodos, // must return a promise; throw to signal an error
  });

  if (isPending) return <span>Loading...</span>;
  if (isError) return <span role="alert">Error: {error.message}</span>;

  return (
    <>
      {isFetching && <small>Refreshing…</small>}
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </>
  );
}
What’s happening
  1. new QueryClient() sits outside the component so it's created once. Created inside App, every render of App would make a new, empty cache.
  2. TodoList mounts and useQuery looks up ["todos"] in the cache. Nothing is there, so the query's status is "pending" and its fetchStatus is "fetching" (the test reads both off the cache). isPending is true → "Loading...".
  3. fetchTodos runs. It throws on a non-2xx response because TanStack Query decides success or failure purely by whether the promise rejects. Axios (in my notes) throws on 4xx/5xx by itself; with fetch you must do it.
  4. The promise resolves, the cache stores the array, status becomes "success" and fetchStatus "idle". The component re-renders with data and shows the list.
  5. Later, a background refetch (invalidation, window focus): status stays "success" and the old data stays on screen, but isFetching flips to true → the test sees "Refreshing…" next to the old list, then the new item. That's stale-while-revalidate.
  6. On a 500: the test client has retries off, so it goes straight to isError → "Error: Failed to load todos (HTTP 500)". With the default client, it retries 3 times first (the test counts 4 calls), with a delay of min(1000 × 2^attempt, 30000) ms between them, so a real user waits about 7 seconds before seeing the error.

The flags, as of v5:

FlagMeans
isPending (status === "pending")No data yet (first load, or the cache entry was removed)
isError / isSuccessLast fetch failed / there is data
isFetching (fetchStatus === "fetching")A request is in flight right now, first load or background
isLoadingisPending && isFetching: first load actually happening
isRefetchingisFetching && !isPending: background refresh

status answers "do I have data?" and fetchStatus answers "is the query function running?". They're independent: a query can be pending + paused (offline, waiting for the network) or success + fetching (refreshing).

When to reach for it: any app with more than a couple of server reads, especially lists, details and dashboards that several components share. Not needed for: one-off fetches in tiny apps, or apps whose framework already loads data per route (Next.js Server Components, React Router loaders), though many of those still use TanStack Query for client-side caching.

#Query keys

My notes flagged this as "Query KEYS: Really important" (the example under it was a screenshot that couldn't be read). It is the most important concept in TanStack Query, because the key is the cache. Every cache entry is identified by its key; same key means same data, shared by every component that asks; different key means a separate entry with its own loading state, fetch and lifetime.

The mental model: a query key is like a file path for the cache. ["todos"] is a folder, ["todos", "list", { status: "open" }] is a file inside it. Two rules follow: every variable the query function uses must be in the key (or two different requests share one file), and invalidation works by folder, so a good hierarchy lets you refresh "everything about todos" in one call.

FilteredTodos.jsx
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";

export const todoKeys = {
  all: ["todos"],
  lists: () => [...todoKeys.all, "list"],
  list: (filters) => [...todoKeys.lists(), filters],
  detail: (id) => [...todoKeys.all, "detail", id],
};

async function fetchTodos({ status }) {
  const res = await fetch(`/api/todos?status=${status}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

export function FilteredTodos() {
  const [status, setStatus] = useState("open");
  const queryClient = useQueryClient();
  const { data, isPending } = useQuery({
    queryKey: todoKeys.list({ status }), // ["todos", "list", { status: "open" }]
    queryFn: () => fetchTodos({ status }),
  });

  return (
    <>
      <button onClick={() => setStatus("open")}>Open</button>
      <button onClick={() => setStatus("done")}>Done</button>
      <button onClick={() => queryClient.invalidateQueries({ queryKey: todoKeys.all })}>
        Refresh all
      </button>
      {isPending ? <p>Loading…</p> : <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>}
    </>
  );
}
What’s happening
  1. todoKeys is a key factory: one place that builds every todo key, so components can't drift into typos like ["todo"] vs ["todos"]. todoKeys.detail(5) is ["todos", "detail", 5].
  2. First render: status is "open", the key is ["todos", "list", { status: "open" }]. Nothing cached → "Loading…" → GET /api/todos?status=open → "Write tests".
  3. Click "Done": status changes, so the key changes. To TanStack Query that's a different query: nothing is cached under it, so isPending is true again, "Loading…" shows, and it fetches ?status=done. The cache now holds two entries; the test reads their hashes: ["todos","list",{"status":"open"}] and ["todos","list",{"status":"done"}].
  4. Click "Open" again: the key is back to the first one, which is still cached (and fresh, as the test client uses staleTime: 60000), so the list appears instantly with no request. The test confirms only two URLs were ever fetched.
  5. "Refresh all" calls invalidateQueries({ queryKey: ["todos"] }). Matching is by prefix, so both lists (and any ["todos", "detail", …]) are marked invalid. Only the list currently on screen (an active query) refetches straight away; the test sees isInvalidated: false on "open" (refetched) and true on "done", which will refetch when it's next used.
  6. If you left status out of the key (queryKey: ["todos"]), both filters would read and write one cache entry: switching filter would show the other filter's todos, and never refetch while fresh. That's the classic query-key bug.

How keys are compared: TanStack Query turns the key into a string with a deterministic hash that sorts object keys. The test shows ["todos", { status: "done", page: 1 }] and ["todos", { page: 1, status: "done" }] both hash to ["todos",{"page":1,"status":"done"}], so object key order doesn't matter. Array order does: ["todos", 1] and [1, "todos"] are different queries. Keys must be serialisable (strings, numbers, plain objects, arrays), not class instances or functions.

AdvancedqueryOptions: key and function travel together

v5 adds queryOptions(), which bundles a key with its function (and options) so the same definition can be used by useQuery, useSuspenseQuery, queryClient.prefetchQuery and queryClient.getQueryData. In TypeScript it also tags the key with the data type, so getQueryData(todoQuery(5).queryKey) is typed without a generic.

React
import { queryOptions, useQuery } from "@tanstack/react-query";

export const todoQuery = (id) =>
  queryOptions({
    queryKey: todoKeys.detail(id),
    queryFn: () => fetch(`/api/todos/${id}`).then((res) => res.json()),
    staleTime: 30_000,
  });

function Todo({ id }) {
  const { data } = useQuery(todoQuery(id));
  // …
}
// elsewhere, e.g. on hover: queryClient.prefetchQuery(todoQuery(id))
What’s happening
  1. todoQuery(5) returns a plain options object: { queryKey: ["todos", "detail", 5], queryFn, staleTime: 30000 }. queryOptions adds nothing at runtime; it's there for types and to make the pattern explicit.
  2. useQuery(todoQuery(id)) and prefetchQuery(todoQuery(id)) now can't disagree about the key or the fetch: prefetch on hover fills exactly the entry the component will read.
  3. It replaces the "key factory plus separate fetch functions" split with one object per query, which is what the TanStack docs recommend for v5 codebases.
Why does this component show stale results when the page changes?
React
function Products({ page }) {
  const { data } = useQuery({
    queryKey: ["products"],
    queryFn: () => fetch(`/api/products?page=${page}`).then((r) => r.json()),
  });
  // …
}
Show answer

page isn't in the key, so every page shares the one cache entry ["products"].

What’s happening
  1. Page 1 mounts: key ["products"] is empty, so it fetches ?page=1 and caches the result under ["products"].
  2. page becomes 2. The key is still ["products"], an existing entry, so TanStack Query has no reason to think anything changed. It shows page 1's data.
  3. Whether it refetches depends only on staleness. With the default staleTime: 0 it refetches on the next trigger (mount, focus) and then page 2's data overwrites page 1's in the shared entry; going back to page 1 shows page 2. With a longer staleTime, it just keeps showing page 1.
  4. Fix: queryKey: ["products", page]. Each page gets its own entry, a key change triggers a fetch, and visited pages come back instantly from cache. Add placeholderData: keepPreviousData to keep showing the old page while the new one loads instead of flashing "Loading…".

#staleTime, gcTime and refetching

TanStack Query has two timers per cache entry, and mixing them up is the most common interview stumble.

  • staleTime: how long data counts as fresh after it was fetched. Fresh data is served from cache with no request. Once stale, it's still shown, but the next trigger (a component mounting, the window regaining focus, the network reconnecting, or an invalidation) refetches it in the background. Default: 0, so data is stale immediately.
  • gcTime (v4: cacheTime): how long an unused entry (no mounted component is using it) is kept in memory before it's deleted. Default: 5 minutes (on the server, Infinity). While it's kept, a remount shows the cached data instantly.

Mental model: staleTime is the "best before" date: until then, don't even check for a newer copy. gcTime is how long the fridge keeps leftovers nobody's eating before throwing them out.

My notes' example set staleTime: 60000 ("until then data is fresh"), which is right, but used the v3/v4 call signature. Here it is in v5 form, with gcTime added:

Users.jsx
import { useQuery } from "@tanstack/react-query";

async function fetchUsers() {
  const res = await fetch("/api/users");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

export function Users({ staleTime = 60_000, gcTime }) {
  const { data, isPending, isFetching } = useQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
    staleTime, // fresh for 60 s: no refetch on mount, focus or reconnect
    gcTime, // how long the data stays cached after the last user unmounts (default 5 min)
  });

  if (isPending) return <div>Loading...</div>;
  return (
    <ul aria-busy={isFetching}>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
What’s happening
  1. staleTime: 60000: the first mount fetches and caches [{ id: 1, name: "Ada" }]. The test unmounts it and mounts a new <Users />. The data is under a minute old, so it's fresh: "Ada" renders on the very first frame and no request is made (the test counts 1 fetch in total).
  2. staleTime: 0 (the default): same steps, but on remount the data is already stale. TanStack Query still renders the cached "Ada" instantly (no "Loading..."), and starts a background refetch: the test sees aria-busy="true" on the list and 2 fetches in total.
  3. gcTime: 50: after the component unmounts, the query has no observers and its gc timer starts. At first getQueryData(["users"]) still returns the array. After 80 ms it returns undefined: the entry was deleted. A new mount is back to a hard "Loading..." state.
  4. Window focus: with stale data, the test simulates the tab losing and regaining focus (focusManager.setFocused(false) then true) and sees a second request. With the 60-second staleTime, the same focus change makes no request.
  5. So staleTime controls when to refetch; gcTime controls when to forget. Data can be stale but cached (shown instantly, refreshed in the background), fresh but about to be collected, and so on.

What triggers a refetch of stale data (each can be turned off per query):

TriggerOptionDefault
A component using the query mountsrefetchOnMounttrue
The window regains focusrefetchOnWindowFocustrue
The network reconnectsrefetchOnReconnecttrue
A timerrefetchInterval (ms)off
You invalidate itinvalidateQueries—

Each option can also be "always" (refetch even if fresh) or false.

Choosing values: there's no universal answer, it's a product decision about how out of date the data may be. Reference data (countries, feature flags) → staleTime: Infinity. A dashboard → 30–60 seconds. Data the user is actively editing elsewhere → keep 0 and rely on invalidation after mutations. Many teams set a default like staleTime: 60_000 on the QueryClient to stop the "refetch on every mount and every focus" behaviour that surprises people (you switch tabs and see a burst of requests in the Network panel).

AdvancedstaleTime: "static" vs Infinity

Since v5.x, staleTime can also be the string "static". Infinity means "never stale by time", but invalidateQueries still marks it invalid and refetchOnWindowFocus: "always" still refetches it. "static" means never stale at all: in the installed query-core, shouldFetchOn returns false for a static query before even looking at the "always" options. Use Infinity for "fresh until I say otherwise", "static" for data that truly never changes during a session.

Two components use the same query with different staleTimes. What happens?

<A /> uses useQuery({ queryKey: ["user"], queryFn, staleTime: 0 }) and <B /> uses the same key with staleTime: 60_000. Both are mounted and the window regains focus.

Show answer

It refetches. Staleness is decided per observer (each useQuery call), and the query refetches if any observer considers it stale and wants to refetch on that trigger.

What’s happening
  1. Both hooks subscribe to the one cache entry ["user"]; there is one piece of data and one fetch.
  2. On focus, query-core asks each observer whether it should refetch. B's 60-second staleTime says "fresh"; A's 0 says "stale".
  3. A's answer is enough: one request goes out and both components get the new data (they share it).
  4. Lesson: when the same key appears in several places, set its options in one place (a queryOptions helper or QueryClient defaults), or the most eager setting quietly wins.

#useMutation and invalidation

Queries read; mutations write. useMutation wraps a function that changes data on the server (POST, PUT, PATCH, DELETE) and tracks its status (isPending, isError, isSuccess, data, error). Unlike useQuery it doesn't run on its own: you call mutate(variables). And it doesn't touch the cache unless you tell it to.

The mental model is write, then mark the photocopies out of date. After a successful write, the server's data has changed, so any cached query that might include it is wrong. The standard move is invalidateQueries with a key prefix: those entries become stale and the active ones refetch, so the UI catches up with the server. That's what my notes' example did; here it is in v5 form, with error handling added.

AddTodo.jsx
import { useMutation, useQueryClient } from "@tanstack/react-query";

async function addTodo(newTodo) {
  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newTodo),
  });
  if (!res.ok) throw new Error(`Could not save (HTTP ${res.status})`);
  return res.json();
}

export function TodoInput() {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      // Returning the promise keeps the mutation pending until the refetch finishes.
      return queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  return (
    <div>
      <button onClick={() => mutation.mutate({ title: "New Todo" })} disabled={mutation.isPending}>
        {mutation.isPending ? "Adding…" : "Add Todo"}
      </button>
      {mutation.isError && <p role="alert">{mutation.error.message}</p>}
    </div>
  );
}
What’s happening
  1. The test renders TodoInput next to a Todos list that uses useQuery({ queryKey: ["todos"] }). The list loads "Existing" (GET /api/todos).
  2. Click → mutation.mutate({ title: "New Todo" }). The mutation goes idle → pending, so the button shows "Adding…" and is disabled (stops double submits). addTodo sends the POST with a JSON Content-Type header (the test checks the header is there).
  3. The server answers 201. onSuccess runs and calls invalidateQueries({ queryKey: ["todos"] }). The list's query is active, so it refetches: the test sees exactly GET, POST, GET.
  4. Because onSuccess returns the invalidation promise, the mutation stays pending until the refetch has finished. The button flips back to "Add Todo" at the same moment "New Todo" appears, with no in-between frame where the write is done but the list is old.
  5. On a 500: addTodo throws, the mutation becomes isError, "Could not save (HTTP 500)" renders, and onSuccess doesn't run, so no refetch (the test counts 2 requests). Without the !res.ok check, the 500 would count as success: fetch resolved fine.

Other ways to sync the cache after a mutation:

  • Invalidate (above): simplest and always correct; costs a refetch.
  • Write the response into the cache: onSuccess: (saved) => queryClient.setQueryData(["todos", saved.id], saved). Saves a request when the server returns the full updated object; you must update every list that contains it, which is why many teams still invalidate the lists.
  • Optimistic update: change the cache before the server answers (next topic).

mutate vs mutateAsync: mutate returns nothing and handles errors through the mutation state. mutateAsync returns a promise you can await, but it rejects on error, so you must catch it or you get an unhandled rejection. Prefer mutate plus the callbacks unless you need to chain.

#Optimistic updates

An optimistic update shows the result of a write before the server confirms it, because most writes succeed and waiting 300 ms for a "like" to register feels broken. The deal you make: if the server says no, you roll back and tell the user.

Mental model: it's writing in pencil. You pencil the new todo into the list straight away, keep a photo of how the list looked before, and when the server answers you either ink it in (refetch the real data) or rub it out (restore the photo).

TanStack Query's recipe has three hooks into the mutation's life: onMutate (before the request: snapshot and write the guess), onError (restore the snapshot), onSettled (success or failure: refetch to get the real thing). This is the flow from my notes, corrected for v5 and for three bugs (below):

OptimisticTodo.jsx
import { useMutation, useQueryClient } from "@tanstack/react-query";

async function addTodo(newTodo) {
  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newTodo),
  });
  if (!res.ok) throw new Error(`Could not save (HTTP ${res.status})`);
  return res.json();
}

export function TodoForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    // 1. Runs before mutationFn: update the cache as if the server already said yes.
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] }); // stop an in-flight GET overwriting us
      const previousTodos = queryClient.getQueryData(["todos"]);
      queryClient.setQueryData(["todos"], (old = []) => [
        ...old,
        { ...newTodo, id: `temp-${Date.now()}`, pending: true },
      ]);
      return { previousTodos }; // handed to onError / onSettled as `onMutateResult`
    },
    // 2. On failure: put the snapshot back.
    onError: (error, newTodo, onMutateResult) => {
      queryClient.setQueryData(["todos"], onMutateResult?.previousTodos);
    },
    // 3. Either way: refetch so the cache matches the server (real id, server fields).
    onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  const handleSubmit = (event) => {
    event.preventDefault();
    mutation.mutate({ title: "Optimistic Update" });
  };

  return (
    <form onSubmit={handleSubmit}>
      <button type="submit">Add Todo</button>
      {mutation.isError && <p role="alert">{mutation.error.message}</p>}
    </form>
  );
}
What’s happening
  1. The list (a useQuery(["todos"]) that renders "(saving…)" for items with pending: true) shows "Existing". Submit → mutate({ title: "Optimistic Update" }) → onMutate runs before the POST is sent.
  2. cancelQueries stops any ["todos"] fetch already in flight. Without it, a GET that started before the click could resolve a moment later and overwrite the optimistic list with server data that doesn't have the new item yet.
  3. getQueryData takes the snapshot ([{ id: 1, title: "Existing" }]), then setQueryData appends { title: "Optimistic Update", id: "temp-…", pending: true }. Every component using ["todos"] re-renders: the test sees "Optimistic Update (saving…)" immediately, and still sees it 50 ms later while the POST (100 ms in the test) is in flight.
  4. Success path: the POST returns, onSettled invalidates, the refetch brings { id: 2, title: "Optimistic Update" } from the server, and the temp item is replaced by the real one. The test checks the cache ends up as exactly the two server todos.
  5. Failure path: the POST returns 500, addTodo throws, onError receives { previousTodos } as its third argument and restores it, so the optimistic item disappears and "Could not save (HTTP 500)" shows. onSettled still refetches, to be safe.
  6. The temp id is there because the list renders key={todo.id}. Without it React logs "Each child in a list should have a unique "key" prop.", which is what happened when my original code ran.
AdvancedThe simpler way: render the pending variables

v5 also supports optimistic UI without touching the cache. While the mutation is pending, its variables are what you sent, so you can render them as a ghost item. On success the refetch brings the real item; on error you show it with a retry.

OptimisticViaUI.jsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

export function TodosWithPending() {
  const queryClient = useQueryClient();
  const { data = [] } = useQuery({
    queryKey: ["todos"],
    queryFn: () => fetch("/api/todos").then((res) => res.json()),
  });
  const { mutate, isPending, isError, variables } = useMutation({
    mutationFn: async (newTodo) => {
      const res = await fetch("/api/todos", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newTodo),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  return (
    <>
      <button onClick={() => mutate({ title: "Buy milk" })}>Add</button>
      <ul>
        {data.map((t) => <li key={t.id}>{t.title}</li>)}
        {isPending && <li style={{ opacity: 0.5 }}>{variables.title}</li>}
        {isError && (
          <li>
            {variables.title} failed <button onClick={() => mutate(variables)}>Retry</button>
          </li>
        )}
      </ul>
    </>
  );
}
What’s happening
  1. Click "Add" → the mutation is pending with variables = { title: "Buy milk" }, so a faded "Buy milk" item renders. The test checks the cache still has one todo: nothing was written to it.
  2. onSettled returns the invalidation promise, so isPending stays true until the refetched list (now containing the real "Buy milk") has arrived. The ghost and the real item swap in one render, with no flicker or duplicate.
  3. On failure, isPending is false and isError true: the item stays visible with "Retry", which calls mutate(variables) again. The test fixes the server, clicks Retry, and gets the real item.
  4. Trade-off: it's simpler and can't corrupt the cache, but only this component sees the ghost. If other components need it, use the cache approach (or useMutationState to read pending mutations elsewhere).

When to be optimistic: actions that almost always succeed and where speed matters (likes, toggles, reordering, adding to a list, chat messages). When not to: payments, anything irreversible, or anything where the server decides the result (a generated id the next screen needs, a price). For React 19's built-in version of the same idea, see useOptimistic.

#Infinite queries and infinite scrolling

Infinite scrolling loads a list page by page as the user scrolls: a feed, search results, a chat history. My notes paired "React-Window & TANSTACK for infinite scrolling" (a heading with nothing under it that could be read). They solve different halves: useInfiniteQuery fetches and caches the pages; a trigger (a "Load more" button, an IntersectionObserver sentinel, or a virtualised list's visible range) decides when to ask for the next one. react-window only matters once the list is long enough that rendering every row is slow.

Mental model: useInfiniteQuery is a query whose data is an array of pages, plus a cursor. Each page tells you where the next one starts (nextCursor). The cache entry looks like { pages: [page0, page1, …], pageParams: [0, 1, …] }, and fetchNextPage() appends one more page.

Feed.jsx
import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";

async function fetchPosts({ pageParam }) {
  const res = await fetch(`/api/posts?cursor=${pageParam}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json(); // { items: [...], nextCursor: number | null }
}

export function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, // undefined = no more pages
  });

  const sentinelRef = useRef(null);
  useEffect(() => {
    const el = sentinelRef.current;
    if (!el || !hasNextPage) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && !isFetchingNextPage) fetchNextPage();
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isPending) return <p>Loading…</p>;
  const posts = data.pages.flatMap((page) => page.items);

  return (
    <>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <div ref={sentinelRef} />
      {isFetchingNextPage && <p>Loading more…</p>}
      {!hasNextPage && <p>You're all caught up.</p>}
    </>
  );
}
What’s happening
  1. First load: queryFn is called with pageParam = initialPageParam = 0 → GET /api/posts?cursor=0 → { items: [Post 1, Post 2], nextCursor: 1 }. data is { pages: [page0], pageParams: [0] }, and flatMap turns the pages into one flat list for rendering.
  2. getNextPageParam(lastPage) returns 1, so hasNextPage is true and the effect starts an IntersectionObserver on the empty <div> under the list (the sentinel).
  3. The user scrolls the sentinel into view (in the test, a fake observer fires isIntersecting: true). The callback calls fetchNextPage() → queryFn({ pageParam: 1 }). While it runs, isFetchingNextPage is true ("Loading more…"), and the effect re-runs with the new flag so the observer can't fire a duplicate request.
  4. Page 1 arrives and is appended: pages has two entries and "Post 4" renders. Same again for cursor 2, whose response has nextCursor: null. ?? undefined turns that into undefined, which is how you tell TanStack Query there are no more pages: hasNextPage becomes false, the effect stops observing (the test checks zero observers are left), and "You're all caught up." shows.
  5. Final cache: pageParams: [0, 1, 2], pages.map(p => p.nextCursor) → [1, 2, null], 6 list items. If the query is refetched (invalidation, focus), TanStack Query refetches every loaded page in order starting from the first, so the pages stay consistent; the test sees cursor=0 then cursor=1 after loading two pages.
  6. The !hasNextPage early return in the effect matters: without it, the observer would keep firing at the bottom of a finished list, and fetchNextPage would be called for nothing.
AdvancedInfinite scrolling with react-window

For thousands of rows, combine the infinite query with a virtualised list so only the visible rows are in the DOM. This uses react-window 2, whose API changed from the v1 FixedSizeList in my notes (see Virtualizing long lists): List takes a rowComponent, a rowCount, a rowHeight and rowProps, and reports the visible range through onRowsRendered.

VirtualFeed.jsx
import { List } from "react-window";
import { useInfiniteQuery } from "@tanstack/react-query";

function Row({ index, style, ariaAttributes, posts, hasNextPage }) {
  if (index === posts.length) {
    return <div style={style} {...ariaAttributes}>{hasNextPage ? "Loading more…" : "End of feed"}</div>;
  }
  return <div style={style} {...ariaAttributes}>{posts[index].title}</div>;
}

export function VirtualFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: ({ pageParam }) => fetch(`/api/posts?cursor=${pageParam}`).then((res) => res.json()),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  if (isPending) return <p>Loading…</p>;
  const posts = data.pages.flatMap((page) => page.items);

  return (
    <List
      rowComponent={Row}
      rowCount={posts.length + 1} // one extra row for the loader / end marker
      rowHeight={40}
      rowProps={{ posts, hasNextPage }}
      style={{ height: 400 }}
      onRowsRendered={({ stopIndex }) => {
        // Within 5 rows of the end: ask for the next page.
        if (stopIndex >= posts.length - 5 && hasNextPage && !isFetchingNextPage) fetchNextPage();
      }}
    />
  );
}
What’s happening
  1. Page 0 has 50 posts, so rowCount is 51 (the extra row is the loader). The list is 400 px tall with 40 px rows: the test counts 13 rows in the DOM, 10 visible plus 3 rows of overscan, "Post 0" to "Post 12", not 51.
  2. Row gets index, style (absolute position and height, which you must apply) and ariaAttributes (role="listitem", aria-posinset, aria-setsize), plus whatever you pass in rowProps. The test found zero list items until ariaAttributes was spread onto the row.
  3. The test scrolls to scrollTop = 1600 (row 40). Now rows "Post 37" to the loader row (index 50) are rendered, and onRowsRendered reports stopIndex = 50 ≥ 50 - 5, so fetchNextPage() runs: a second request for cursor=1.
  4. Page 1 arrives, posts.length becomes 100, the loader row moves to index 100, and "Post 50" to "Post 52" render where the loader was. Still fewer than 20 rows in the DOM out of 101, and still exactly 2 requests: the isFetchingNextPage guard stopped repeated calls while the page was loading.
  5. Pass rowProps rather than closing over posts in Row: react-window re-renders rows when rowProps values change, and Row stays a stable component defined outside VirtualFeed.

Practical notes:

  • Use cursor-based pagination for feeds (the server returns "start after this id"). Offset pagination (?page=3) duplicates or skips items when rows are added while the user scrolls.
  • Infinite scroll hurts footer access, "jump back to where I was", and accessibility. A "Load more" button (onClick={() => fetchNextPage()}) is often the better UX, and it's the same hook.
  • For numbered pages (1, 2, 3 with prev/next), use a normal useQuery with the page in the key and placeholderData: keepPreviousData, not an infinite query.

#use() with Suspense for data

Added

Everything so far handles loading inside the component: if (isPending) return <Spinner />. Suspense flips that around. A component just reads the data as if it were already there; if it isn't, the component suspends, and the nearest <Suspense> boundary above it shows a fallback until it's ready. Errors work the same way with an error boundary.

React 19's use(promise) is the built-in way to read a promise like this. Mental model: use is "await for render". If the promise is fulfilled, use returns the value; if it's pending, React pauses this part of the tree and shows the fallback; if it rejected, use throws the error to the nearest error boundary.

The catch, and the reason libraries exist: the promise must be created outside render and be the same object on every render. If you call fetch during render, each render makes a new promise, which never settles in time, so the component never gets out of the fallback.

SuspenseUser.jsx
import { Suspense, use } from "react";
import { ErrorBoundary } from "react-error-boundary";

// A tiny cache so the SAME promise is returned on every render for the same id.
const cache = new Map();
function fetchUser(id) {
  if (!cache.has(id)) {
    cache.set(
      id,
      fetch(`/api/users/${id}`).then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      }),
    );
  }
  return cache.get(id);
}

function UserName({ userPromise }) {
  const user = use(userPromise); // suspends until the promise settles
  return <h2>{user.name}</h2>;
}

export function UserCard({ id }) {
  return (
    <ErrorBoundary fallback={<p role="alert">Could not load user</p>}>
      <Suspense fallback={<p>Loading user…</p>}>
        <UserName userPromise={fetchUser(id)} />
      </Suspense>
    </ErrorBoundary>
  );
}
What’s happening
  1. UserCard renders and calls fetchUser(1). The cache is empty, so it starts the request and stores the promise (not the result) under 1.
  2. UserName calls use(userPromise). The promise is pending, so UserName suspends: React stops rendering that subtree and commits the nearest boundary's fallback, "Loading user…".
  3. The promise resolves. React re-renders UserCard; fetchUser(1) returns the same promise from the map, which React now knows is fulfilled, so use returns { name: "Ada" } synchronously and the heading renders. The test counts exactly one fetch.
  4. If the server returns 500, the promise rejects, use throws that error during render, and the ErrorBoundary above shows "Could not load user". There's no isError branch inside UserName: it only ever deals with the happy path.
  5. If you wrote use(fetch(...).then(r => r.json())) inside the component, each retry would create a new promise. In the test that version never left "Loading…" and sent 3 to 5 requests in 200 ms (the count varied between runs). In some timings React also logs "A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework." (see use()).

In real apps you rarely hand-roll that cache. TanStack Query's useSuspenseQuery gives the same Suspense behaviour with a real cache; its data is never undefined, so there are no loading branches:

React
import { useSuspenseQuery } from "@tanstack/react-query";

function UserNameQuery({ id }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ["user", id],
    queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
  });
  return <h2>{user.name}</h2>;
}
// <Suspense fallback={<p>Loading user…</p>}><UserNameQuery id={3} /></Suspense>
What’s happening
  1. On first render the ["user", 3] entry is empty, so useSuspenseQuery starts the fetch and suspends (internally it throws the query's promise, which is stable because it lives in the cache).
  2. The <Suspense> boundary shows "Loading user…" (the test sees it), then "Grace" once the cache is filled.
  3. Errors are thrown to the nearest error boundary rather than returned as isError.
  4. Everything else from earlier topics still applies: keys, staleTime, background refetches (which don't re-suspend; the old data stays on screen).

Where Suspense for data fits:

  • Frameworks do it best: Next.js Server Components pass promises to Client Components that use them, and routers with loaders start fetches before the route renders.
  • Fetch-then-render, not fetch-on-render. Start the request as early as possible (in a route loader, an event handler, or a parent) and pass the promise down. If each component starts its own request when it renders, you get waterfalls: the child can't start until the parent has finished.
  • One boundary can cover several components that load in parallel, so the user sees one spinner instead of five popping in at different times. How boundaries and transitions interact is in Suspense boundaries.

#WebSockets in React

A WebSocket is a persistent, two-way connection between browser and server. After an HTTP upgrade handshake, either side can send messages at any time, which makes it the tool for chat, multiplayer, collaborative editing and live dashboards where the client also talks back.

In React terms, a socket is an external system the component synchronises with, so it belongs in useEffect with a cleanup: open it when the component mounts (or when the room changes), close it when it unmounts. Mental model: the effect is a phone call. The dependency array says who you're calling; changing it means hanging up and dialling again; the cleanup is hanging up.

My notes had the right shape: open in an effect, append messages with a functional update, ws.close() in the cleanup. Here's a version that adds what a real chat needs: the room in the dependency array, JSON messages with ids, a send function, and a cleanup that doesn't report its own close as a drop.

ChatRoom.jsx
import { useEffect, useRef, useState } from "react";

export function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [status, setStatus] = useState("connecting");
  const socketRef = useRef(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);
    socketRef.current = ws;
    setMessages([]);
    setStatus("connecting");

    ws.onopen = () => setStatus("connected");
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data); // { id, text }
      setMessages((prev) => [...prev, message]);
    };
    ws.onerror = () => setStatus("error");
    ws.onclose = () => setStatus("disconnected");

    return () => {
      ws.onclose = null; // we're closing on purpose: don't report it as a drop
      ws.close();
    };
  }, [roomId]); // a new room means a new connection

  function send(text) {
    const ws = socketRef.current;
    if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ text }));
  }

  return (
    <div>
      <p>Status: {status}</p>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>{m.text}</li>
        ))}
      </ul>
      <button onClick={() => send("hello")} disabled={status !== "connected"}>
        Say hello
      </button>
    </div>
  );
}
What’s happening
  1. Mount with roomId="general": the effect opens wss://chat.example.com/rooms/general. The socket starts in CONNECTING, so the status says "connecting" and the button is disabled (calling send on a connecting socket throws InvalidStateError in browsers).
  2. The server accepts → onopen → "connected". Two messages arrive; each onmessage parses the JSON and appends with setMessages(prev => [...prev, message]). The functional update matters: the handler was created once, when the effect ran, so a plain [...messages, message] would always see the empty array from that first render (a stale closure) and you'd only ever keep the latest message.
  3. Clicking "Say hello" reads the live socket from socketRef (a ref, because the socket isn't something to render) and sends {"text":"hello"}; the test checks the fake socket received exactly that string.
  4. roomId changes to "random": React runs the old cleanup (closes the general socket, readyState 3) and then the new effect (opens rooms/random, clears the messages). Setting ws.onclose = null first means the old socket's close event doesn't flip the new room's status to "disconnected".
  5. Unmount → cleanup closes the last socket. Without the cleanup, every navigation would leak an open connection still calling setMessages on a component that's gone.
  6. In <StrictMode> (development), the test sees two sockets: the first closed by the simulated unmount (readyState 3), the second live. In Chrome, closing a socket that's still connecting logs "WebSocket is closed before the connection is established." during development; that's this double-mount, not a bug (I couldn't run a real browser here, so that message is from Chrome, not the sandbox).
AdvancedReconnecting, and pushing socket data into the query cache

A production hook reconnects with exponential backoff (wait 1 s, 2 s, 4 s … so a server that's down isn't hammered by every client at once) and uses useEffectEvent so the message handler can read the latest props without being a dependency (which would reconnect on every render). Pushing messages into TanStack Query's cache with setQueryData lets every useQuery of that key update live, while the initial snapshot still comes from a normal fetch.

useSocket.jsx
import { useEffect, useEffectEvent } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";

// Reconnects with exponential backoff: 1 s, 2 s, 4 s … capped at 30 s.
export function useSocket(url, onMessage) {
  const handleMessage = useEffectEvent(onMessage); // always the latest onMessage, never a dependency

  useEffect(() => {
    let ws;
    let attempt = 0;
    let timer;
    let stopped = false;

    function connect() {
      ws = new WebSocket(url);
      ws.onopen = () => {
        attempt = 0;
      };
      ws.onmessage = (event) => handleMessage(JSON.parse(event.data));
      ws.onclose = () => {
        if (stopped) return;
        const delay = Math.min(1000 * 2 ** attempt, 30_000);
        attempt++;
        timer = setTimeout(connect, delay);
      };
    }
    connect();

    return () => {
      stopped = true;
      clearTimeout(timer);
      ws.close();
    };
  }, [url]);
}

// Server pushes go straight into the query cache, so every useQuery(["prices"]) re-renders.
export function useLivePrices() {
  const queryClient = useQueryClient();
  useSocket("wss://prices.example.com", (update) => {
    queryClient.setQueryData(["prices"], (old = {}) => ({ ...old, [update.symbol]: update.price }));
  });
  return useQuery({
    queryKey: ["prices"],
    queryFn: () => fetch("/api/prices").then((res) => res.json()), // initial snapshot
    staleTime: Infinity, // the socket keeps it fresh; never refetch on focus
  });
}
What’s happening
  1. useLivePrices fetches the snapshot { AAPL: 100 } into ["prices"] and opens the socket. A message { symbol: "AAPL", price: 101.5 } goes through setQueryData, and a Ticker component reading the query renders "AAPL 101.5". The test has to await findByText here because TanStack Query batches cache notifications and delivers them on a later tick.
  2. Drops (with fake timers): first onclose → wait 1000 × 2^0 = 1000 ms (nothing at 999 ms, a new socket at 1000), then 2000 ms, then 4000 ms. attempt counts consecutive failures.
  3. A successful onopen resets attempt to 0, so the next drop waits 1 s again; the test confirms socket number 5 appears after 1000 ms.
  4. Unmount sets stopped, clears any pending timer and closes the socket; advancing time by a minute creates no new sockets. Without stopped, the cleanup's own ws.close() would fire onclose and schedule a reconnect after the component was gone.
  5. useEffectEvent (React 19.2) is why the effect depends only on [url]: the inline onMessage arrow is a new function every render, and as a dependency it would tear down and reopen the socket on every render. See useEffectEvent.
  6. Real systems add jitter (a random extra delay so thousands of clients don't reconnect in sync), heartbeats (ping/pong to detect dead connections that never fire onclose), and re-subscribing to channels after a reconnect. Libraries like Socket.IO or a hosted service handle these.

Where state goes: for a component-local feed (one chat window), useState in the component is fine. If several components need the live data, put the socket in one place (a context provider, a store, or the query cache as above) rather than opening a socket per component. Browsers limit connections, and duplicate sockets mean duplicate messages.

#Server-Sent Events

Server-Sent Events (SSE) are a one-way stream from server to browser over plain HTTP. The client opens a long-lived GET with new EventSource(url); the server keeps the response open and writes text/event-stream lines (data: …, optionally event: goal and id: 42). The browser parses them into events.

Mental model: a WebSocket is a phone call (both sides talk); SSE is a radio station (you tune in and listen). It's a great fit when the server pushes and the client only listens: notifications, live scores, progress of a long job, streaming LLM responses. The browser also gives you automatic reconnection for free: if the connection drops, it reconnects and sends a Last-Event-ID header so the server can resume.

LiveScores.jsx
import { useEffect, useState } from "react";

export function LiveScores({ matchId }) {
  const [events, setEvents] = useState([]);
  const [status, setStatus] = useState("connecting");

  useEffect(() => {
    const source = new EventSource(`/api/matches/${matchId}/events`);

    source.onopen = () => setStatus("live");
    source.onmessage = (event) => {
      // Unnamed events ("data: ..." with no "event:" line)
      setEvents((prev) => [...prev, { id: event.lastEventId, text: event.data }]);
    };
    // Named events ("event: goal") only reach addEventListener, not onmessage.
    const onGoal = (event) => setEvents((prev) => [...prev, { id: event.lastEventId, text: `GOAL: ${event.data}` }]);
    source.addEventListener("goal", onGoal);

    source.onerror = () => {
      // The browser reconnects by itself unless readyState is CLOSED.
      setStatus(source.readyState === EventSource.CLOSED ? "closed" : "reconnecting");
    };

    return () => {
      source.removeEventListener("goal", onGoal);
      source.close();
    };
  }, [matchId]);

  return (
    <section>
      <p>Status: {status}</p>
      <ol>
        {events.map((e) => (
          <li key={e.id}>{e.text}</li>
        ))}
      </ol>
    </section>
  );
}
What’s happening
  1. The effect opens /api/matches/42/events. When the server responds with Content-Type: text/event-stream, onopen fires → "live".
  2. The server writes id: 1 / data: Kick-off. That has no event: line, so it's a message event → onmessage appends "Kick-off". Then id: 2 / event: goal / data: Messi 23', a named event, which only addEventListener("goal", …) receives; onmessage never sees it. The list becomes ["Kick-off", "GOAL: Messi 23'"].
  3. A network blip: the browser fires error with readyState back at CONNECTING (0) and retries on its own, after a delay the server can set with a retry: line. The status shows "reconnecting" and the test confirms there is still only one EventSource: we don't create a new one; the browser reuses it. When it reconnects, onopen fires again → "live", and the browser sends Last-Event-ID: 2 so the server can replay what was missed.
  4. A fatal failure (HTTP 500, wrong content type, a 204) fires error with readyState CLOSED (2): the browser has given up, so the status shows "closed". At that point a retry is up to you.
  5. Unmount → close(). Unlike a WebSocket there's no message to send; close() just aborts the HTTP request.

How they compare (a common interview question):

Polling (refetchInterval)Server-Sent EventsWebSocket
DirectionClient asks repeatedlyServer → clientBoth ways
ProtocolNormal HTTP requestsOne long HTTP responseUpgraded TCP connection (ws:/wss:)
DataAnythingUTF-8 text (send JSON as text)Text or binary
ReconnectN/ABuilt in, with Last-Event-IDYou write it
Works through proxies, HTTP/2, auth cookiesYesYes (plain HTTP)Usually, but some proxies need configuring
Good forData that changes every minute or soNotifications, feeds, progress, streaming AI outputChat, games, collaboration

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.