Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Context selectors in React

Turtlekit Editorial Team

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.

The value in context: every reader rendersworkspaceProgressTotalsNameA store in context: one reader rendersstoreProgressTotalsName
On each tick of the sync, a value in context re-renders every reader. With a store in context, each reader compares its own slice, and only the progress bar’s slice has changed.

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.

What renders on one tick of the sync
ComponentValue in contextStore in context
SyncProgressRendersRenders
SyncStatusRendersStays, until the sync starts or ends
InvoiceTotalsRendersStays
Any other readerRendersRenders 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.

Reading context with use() in React 19

When the context changes rarely, and the question is where to read it.

State colocation in React

When the busy value has one reader, and belongs in that reader instead.

Lifting state up in React

When two siblings share it, and props are simpler than context.

Deriving state during render

When a selector computes from the slice, and must not build a new object.