During a bank sync, the dashboard’s search box stuttered. Typing a customer’s name took a beat longer than it should, but only while the progress bar at the top was moving, and only on accounts with a long invoice table.
The progress lived in the workspace context, next to the account’s name and money format, and the sync updated it several times a second. Every component that read the context re-rendered on every update, the invoice table included, although all it read was the currency.
A context selector lets each component subscribe to the slice of shared state it reads, and re-render only when that slice changes. React has no selector for context built in, so the pattern is a small store passed through context and read with useSyncExternalStore.
One busy value, every reader
Here is the provider, and one of the components that read it:
// Avoid: a value that changes several times a second, read by
// everyone
export function WorkspaceProvider({
initial,
subscribeToSync,
children,
}: ProviderProps) {
const [workspace, setWorkspace] = useState(initial);
useEffect(
() =>
subscribeToSync((p) => setWorkspace((w) => withProgress(w, p))),
[subscribeToSync],
);
return (
<WorkspaceContext value={workspace}>{children}</WorkspaceContext>
);
}
export function useWorkspaceValue(): Workspace {
const workspace = use(WorkspaceContext);
if (!workspace)
throw new Error("useWorkspaceValue needs a <WorkspaceProvider>.");
return workspace;
}
export function InvoiceTotals({
outstanding,
}: {
outstanding: number;
}) {
// Reads the money format, and re-renders on every tick of the sync.
const { locale, currency } = useWorkspaceValue().account;
const format = new Intl.NumberFormat(locale, {
style: "currency",
currency,
});
return <p>Outstanding: {format.format(outstanding / 100)}</p>;
}Each tick of the sync creates a new workspace object, so the provider’s value changes, and React re-renders every component that reads it. The totals re-render to show the same amount in the same format. Wrapping them in memo doesn’t help, because a context update reaches its readers whatever their props.
A context has no way to say “only tell me about the currency”. It delivers one value, and a reader takes all of it or none of it.
Pass the store, not the state
Put the store in context, not the state. A store is an object whose identity never changes, with a way to read its current value and a way to hear about changes. The context delivers it once; from then on each reader subscribes and decides for itself whether a change concerns it.
useSyncExternalStore does the deciding. It reads a snapshot after every change, compares it with the last one using Object.is, and re-renders the component only when it differs. A selector makes that snapshot the slice the component reads.
Building a selector over a small store
Before building anything, try the cheaper fix. Then come the store, the provider, the selector hook and the dashboard that uses them.
First, try splitting the context
When the busy value and the quiet ones can be separated, two contexts may be all you need: one for the account, which changes when someone edits the settings, and one for the sync. A reader of the account context never hears about the sync, so the split alone would calm the totals.
It stops there, because the sync has readers of its own that want different parts of it. The progress bar wants every tick; the status line, and a “Sync now” button beside it, only need to know whether a sync is running. In a sync context they all render on every tick. A store is for that case: a busy value whose readers each want a different part of it.
A store with three functions
The store holds a value, replaces it with an update, and tells its listeners:
// A small external store: a value, a way to change it, and a way to
// hear about changes. Its functions never change, so React subscribes
// once.
type Listener = () => void;
export interface Store<T> {
get: () => T;
set: (update: (current: T) => T) => void;
subscribe: (listener: Listener) => () => void;
}
export function createStore<T>(initial: T): Store<T> {
let value = initial;
const listeners = new Set<Listener>();
return {
get: () => value,
set(update) {
const next = update(value);
if (Object.is(next, value)) return;
value = next;
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}set skips an update that returns the same value, and the functions close over value instead of using this, so subscribe can be passed around on its own and keeps its identity.
A provider whose value never changes
The provider creates its store once and puts the store, not the workspace, in context. The sync writes to the store:
const WorkspaceStore = createContext<Store<Workspace> | null>(null);
interface ProviderProps {
initial: Workspace;
subscribeToSync: SubscribeToSync;
children: ReactNode;
}
export function WorkspaceProvider({
initial,
subscribeToSync,
children,
}: ProviderProps) {
// One store for the provider's life, so the context's value never
// changes.
const [store] = useState(() => createStore(initial));
useEffect(
() =>
subscribeToSync((p) => store.set((w) => withProgress(w, p))),
[store, subscribeToSync],
);
return <WorkspaceStore value={store}>{children}</WorkspaceStore>;
}Selecting a slice
The hook reads the store from context and subscribes the component to one slice:
/**
* The slice of the workspace that `select` picks. The component
* re-renders only when that slice changes, so return a primitive or a
* part of the workspace as it is, never a new object.
*/
export function useWorkspace<T>(
select: (workspace: Workspace) => T,
): T {
const store = use(WorkspaceStore);
if (!store)
throw new Error(
"useWorkspace needs a <WorkspaceProvider> above it.",
);
const read = () => select(store.get());
// The same read on the server and during hydration: the store
// starts from props.
return useSyncExternalStore(store.subscribe, read, read);
}read is the snapshot. While the store hasn’t changed, it must return the same value each time it is called, which is why a selector returns a primitive or a part of the workspace as it is. Because updates copy only what changed, w.account is the same object before and after a sync tick.
The dashboard
"use client";
// The dashboard: the progress bar reads the sync's progress, the
// status line its status, the totals the money format. A sync tick
// re-renders the bar.
import { useId } from "react";
import { WorkspaceProvider, useWorkspace } from "./workspace-store";
import type { SubscribeToSync, Workspace } from "./workspace";
export function SyncProgress() {
const id = useId();
const progress = useWorkspace((w) => w.sync.progress);
return (
<div>
<span id={id}>Bank sync</span>
<progress aria-labelledby={id} value={progress} max={1} />
</div>
);
}
export function SyncStatus() {
// Reads the status alone: two renders per sync, not one per tick.
const status = useWorkspace((w) => w.sync.status);
return (
<p role="status">
{status === "syncing"
? "Syncing with your bank."
: status === "done"
? "Bank sync finished."
: ""}
</p>
);
}
export function InvoiceTotals({
outstanding,
}: {
outstanding: number;
}) {
const locale = useWorkspace((w) => w.account.locale);
const currency = useWorkspace((w) => w.account.currency);
const format = new Intl.NumberFormat(locale, {
style: "currency",
currency,
});
return <p>Outstanding: {format.format(outstanding / 100)}</p>;
}
interface DashboardProps {
workspace: Workspace;
subscribeToSync: SubscribeToSync;
outstanding: number;
}
export function Dashboard({
workspace,
subscribeToSync,
outstanding,
}: DashboardProps) {
return (
<WorkspaceProvider
initial={workspace}
subscribeToSync={subscribeToSync}
>
<main>
<h1>{workspace.account.name}</h1>
<SyncProgress />
<SyncStatus />
<InvoiceTotals outstanding={outstanding} />
</main>
</WorkspaceProvider>
);
}Run a sync and the progress bar moves with every tick, while the totals render once and stay. A screen reader hears “Syncing with your bank.” when the sync starts and “Bank sync finished.” when it ends, and nothing in between.
| Component | Value in context | Store in context |
|---|---|---|
| SyncProgress | Renders | Renders |
| SyncStatus | Renders | Stays, until the sync starts or ends |
| InvoiceTotals | Renders | Stays |
| Any other reader | Renders | Renders only if its slice changed |
What moving state out of React costs
A store solves the re-renders by stepping outside React’s state, and that has a price.
Split before you select
Two contexts are free: no new code to own, and React’s ordinary rules. A store is a small piece of infrastructure that every reader depends on. React’s own reference recommends built-in state over external stores wherever it is possible, so a store has to earn its place with a measured problem.
What a store gives up
Updates to an external store can’t be marked as non-blocking transitions. When a transition renders while the store changes, React falls back to a blocking render so every component shows the same version of the store. The same reference advises against suspending on a value read from a store, which would show the nearest Suspense fallback in place of content already on screen.
Selectors that build new values
A selector that returns a new object, such as (w) => ({ locale, currency }), returns something different on every call. React then sees a change after every check, re-renders in a loop and stops with an error. Select each primitive separately, as the totals do, or return a part of the state that already exists.
Progress without a running commentary
The progress bar’s value changes silently, which is how a progress bar should behave for assistive technology: anyone who wants the figure can move to it. The status line changes twice per sync, at the start and the end. Announcing every tick would bury everything else the screen reader has to say.
Counting renders in a test
The pattern’s whole promise is a render that doesn’t happen, so the test counts renders. A reader of the locale calls a spy each time it renders, under each kind of provider:
import { act, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { SyncProgress, SyncStatus } from "./dashboard";
import * as plain from "./workspace-context-before";
import { WorkspaceProvider, useWorkspace } from "./workspace-store";
import type { SubscribeToSync, Workspace } from "./workspace";
const WORKSPACE: Workspace = {
account: { name: "Halden & Co", locale: "en-GB", currency: "EUR" },
sync: { status: "idle", progress: 0 },
};
/** A bank sync the test moves by hand. */
function heldSync() {
let report = (_progress: number) => {};
const subscribeToSync: SubscribeToSync = (onProgress) => {
report = onProgress;
return () => {};
};
return {
subscribeToSync,
tick: (progress: number) => act(() => report(progress)),
};
}
test("a sync tick moves the bar, not a locale reader", () => {
const rendered = vi.fn();
function LocaleReader() {
rendered();
return <p>{useWorkspace((w) => w.account.locale)}</p>;
}
const sync = heldSync();
render(
<WorkspaceProvider
initial={WORKSPACE}
subscribeToSync={sync.subscribeToSync}
>
<SyncProgress />
<SyncStatus />
<LocaleReader />
</WorkspaceProvider>,
);
for (const progress of [0.25, 0.5, 0.75]) sync.tick(progress);
expect(
screen.getByRole("progressbar", { name: "Bank sync" }),
).toHaveAttribute("value", "0.75");
expect(screen.getByRole("status")).toHaveTextContent(
"Syncing with your bank.",
);
expect(rendered).toHaveBeenCalledTimes(1);
sync.tick(1);
expect(screen.getByRole("status")).toHaveTextContent(
"Bank sync finished.",
);
});
test("with the whole value in context, it renders every tick", () => {
const rendered = vi.fn();
function LocaleReader() {
rendered();
return <p>{plain.useWorkspaceValue().account.locale}</p>;
}
const sync = heldSync();
render(
<plain.WorkspaceProvider
initial={WORKSPACE}
subscribeToSync={sync.subscribeToSync}
>
<LocaleReader />
</plain.WorkspaceProvider>,
);
for (const progress of [0.25, 0.5, 0.75]) sync.tick(progress);
expect(rendered).toHaveBeenCalledTimes(4);
});Both pass: one render with the store, four with the value in context, one for mounting and one per tick.
One store per request
The third argument to useSyncExternalStore is the snapshot used on the server and during hydration, and it has to match on both sides. Creating the store in the provider, from props, makes that true, and keeps every request’s store its own. A store created at the top of a module would be shared by every request the server handles, and one customer’s workspace could reach another’s page.
React 18, 19 and the Compiler
useSyncExternalStore has been in React since 18, so the store and the selector run on both versions; React 19 adds the <Context value> provider and use used here. The React Compiler doesn’t remove the need: a component that reads a changed context still renders, and the Compiler can only make that render cheaper.
When a plain context is enough
Most contexts hold values that change rarely: a theme, a locale, the signed-in person. Every reader re-renders when one changes, and that is what should happen. A selector over those adds code for no render it saves.
Measure first. The React DevTools Profiler shows which components render on an update and how long they take; if the readers are few or cheap, the plain context costs nothing worth fixing. And a busy value with one reader belongs in that reader, as State colocation in React argues.
How Turtlekit’s toasts use a selector
Toast keeps its queue in a store you create with createToastStore and pass to ToastProvider. useToast reads the store from context, and useToastSelector subscribes a component to one fact about it, so a component that shows one toast re-renders only when that toast changes:
// Background work with Turtlekit's toasts: the store lives in
// context, and useToastSelector subscribes a component to the one
// fact it shows.
import { useState } from "react";
import type { ReactNode } from "react";
import {
Badge,
Button,
ToastProvider,
createToastStore,
useToast,
useToastSelector,
} from "turtlekit";
function ExportRunning() {
// A boolean: re-renders when an export starts or ends, not when a
// toast pauses under the pointer.
const running = useToastSelector((toasts) =>
toasts.getIds().some((id) => toasts.get(id)?.tone === "loading"),
);
return running ? <Badge tone="info">Export running</Badge> : null;
}
function ExportButton({
onExport,
}: {
onExport: () => Promise<unknown>;
}) {
const toast = useToast();
return (
<Button
variant="soft"
onClick={() =>
toast.promise(onExport(), {
loading: "Exporting invoices…",
success: "Export ready",
danger: "Couldn’t export the invoices. Try again.",
})
}
>
Export invoices
</Button>
);
}
export function InvoiceToolbar({
onExport,
children,
}: {
onExport: () => Promise<unknown>;
children: ReactNode;
}) {
// One store per mounted toolbar, never one shared by every request.
const [store] = useState(() => createToastStore());
return (
<ToastProvider store={store}>
<ExportRunning />
<ExportButton onExport={onExport} />
{children}
</ToastProvider>
);
}The badge appears while an export runs and goes when it ends. Other toasts coming and going, or pausing under the pointer, leave it as it is, because the one fact it shows hasn’t changed.
Built on this pattern in Turtlekit: Toast.
When selectors misbehave
Three symptoms cover most of the trouble with a store behind a context.
“The result of getSnapshot should be cached”
A selector returns a new object or array on every call. Return primitives, or parts of the state that already exist.
Every reader still renders on every change
Check what the provider passes. A store created in the component’s body, rather than once with useState, is a new store each render, and a selector that returns the whole state changes whenever anything does.
The server’s HTML and the first client render disagree
The server snapshot differs from the client’s first read, often because something wrote to a module-level store before hydration. Create the store from props in the provider, and pass the same read as the third argument.
Hear only what you read
Context is a delivery mechanism, and it is very good at delivering one thing to many places. It was never meant to be a broadcast channel for a value that changes several times a second. Passing a store instead keeps the delivery and moves the listening to each reader, where the knowledge of what matters already lives. Most apps need that once or twice, for the one value that is truly busy, and the rest of their contexts can stay as plain as they are.