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.

useSyncExternalStore in React

Turtlekit Editorial Team

Before her call with the accountant, the finance lead turned on “Show amounts with VAT” at the top of the invoice list. The switch moved. The outstanding total and the table under it didn’t, and she read Northwind’s invoice out at £400.00, the amount before VAT. When she reloaded the page, the switch, the total and the table all showed VAT, and Northwind’s invoice read £480.00.

The preference lived in localStorage, outside React, and every component that showed an amount kept a copy of it in state. The switch updated its own copy. Nothing told the others.

useSyncExternalStore is how React reads a value it doesn’t own. You give it a way to subscribe to the value and a way to read it. Every component that calls it then reads the same value in the same render, and renders again when the value changes.

A copy in every component

Here is the hook each component called. It looks like a reasonable custom hook:

// Avoid: a copy in state per caller, read from storage during render
export function useVatPreference() {
  const [withVat, setWithVat] = useState(
    () => localStorage.getItem(KEY) === "true",
  );

  useEffect(() => {
    // Fires only in the other tabs, never in the one that wrote.
    function onStorage(event: StorageEvent) {
      if (event.key === KEY) setWithVat(event.newValue === "true");
    }
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, []);

  function change(next: boolean) {
    localStorage.setItem(KEY, String(next));
    setWithVat(next);
  }

  return [withVat, change] as const;
}

Each call creates its own state. The switch, the total and the table hold three copies, seeded from storage when each mounted, and change updates only the copy of the component that called it. The storage event looks like the mechanism that keeps them in step, but the browser fires it in the other tabs on the same origin, never in the tab that wrote. In the tab where she flipped the switch, nothing told the total or the table.

There is a second fault. The initializer reads localStorage during render, and on the server there is no localStorage: the page fails to render there, or renders one value on the server and another in the browser.

Read the value where it lives

Stop copying the value into React. Keep it where it lives, and give React two functions: one that subscribes to changes, and one that reads the current value, called a snapshot. useSyncExternalStore calls the reader during render and renders again whenever the subscription reports a change.

another tab writesstorage eventlocalStorageand its readerssetWithVatVatSwitchOutstandingTotalInvoiceTable
The value lives in one place outside React. Writes go to the store, from this tab or another, and every component reads the same snapshot from it, through useWithVat, in the same render.

One value, many readers, no copies. Writes go to the store, and every component asks the store instead of remembering its own answer.

Building the store the switch and the table share

The store is one module, vat-preference.ts, in three parts: the subscription, the snapshot, and the two functions the page uses.

Subscribe: hear about every change

function subscribe(onChange: () => void) {
  readers.add(onChange);
  // Another tab wrote it: the browser tells all the other tabs.
  function onStorage(event: StorageEvent) {
    if (event.key === KEY || event.key === null) onChange();
  }
  window.addEventListener("storage", onStorage);
  return () => {
    readers.delete(onChange);
    window.removeEventListener("storage", onStorage);
  };
}

React calls subscribe with a callback and keeps the function it returns to unsubscribe. Changes come from two places, so the callback is wired to both: the module’s own set of readers for this tab, and the storage event for the others. A null key means another tab cleared storage altogether. Because subscribe is declared once, at module level, React subscribes once per component; a new function on every render would make it unsubscribe and subscribe again each time.

A snapshot React can compare

// A boolean compares by value: each read of one state is equal.
function getSnapshot() {
  try {
    return localStorage.getItem(KEY) === "true";
  } catch {
    return false; // Storage can be blocked; amounts show without VAT.
  }
}

// No localStorage on the server: the first HTML is before VAT.
const getServerSnapshot = () => false;

React compares snapshots with Object.is to decide whether anything changed, so the same state must read as the same value. A boolean does that for free. Reading storage can throw when it is blocked, so the snapshot falls back to amounts before VAT. On the server, getServerSnapshot answers instead, and it is also the value React uses while it hydrates the server’s HTML.

Pitfall

A snapshot that builds a new object on every call, such as JSON.parse(localStorage.getItem(key)), never compares equal, so React keeps re-rendering and warns that “the result of getSnapshot should be cached”. Keep the last raw string and the object parsed from it, and return the same object until the string changes, as react.dev’s reference for useSyncExternalStore shows.

Write, and tell this tab

export function setWithVat(next: boolean) {
  try {
    localStorage.setItem(KEY, String(next));
  } catch {
    return;
  }
  // The tab that wrote gets no storage event: tell its readers.
  for (const onChange of readers) onChange();
}

export function useWithVat() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
}

setWithVat writes to storage, which reaches the other tabs through their storage events, and then calls this tab’s readers itself, since the browser won’t. useWithVat is the only thing components import: the store’s functions stay private.

Every reader, one value

// The invoice list: the switch, the total and the table all read the
// one VAT preference, so they can't disagree.
import { total, shown } from "./invoices";
import type { Invoice } from "./invoices";
import { setWithVat, useWithVat } from "./vat-preference";

function VatSwitch() {
  const withVat = useWithVat();
  return (
    <label>
      <input
        type="checkbox"
        role="switch"
        checked={withVat}
        onChange={(event) => setWithVat(event.target.checked)}
      />{" "}
      Show amounts with VAT
    </label>
  );
}

function OutstandingTotal({ invoices }: { invoices: Invoice[] }) {
  const withVat = useWithVat();
  return (
    <p>
      Outstanding: {total(invoices, withVat)}{" "}
      {withVat ? "with VAT" : "before VAT"}
    </p>
  );
}

function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  const withVat = useWithVat();
  return (
    <table>
      <caption>Open invoices</caption>
      <thead>
        <tr>
          <th scope="col">Invoice</th>
          <th scope="col">Customer</th>
          <th scope="col">
            {withVat ? "Amount with VAT" : "Amount before VAT"}
          </th>
        </tr>
      </thead>
      <tbody>
        {invoices.map((invoice) => (
          <tr key={invoice.id}>
            <th scope="row">{invoice.number}</th>
            <td>{invoice.customer}</td>
            <td>{shown(invoice, withVat)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export function InvoicesPage({ invoices }: { invoices: Invoice[] }) {
  return (
    <section aria-labelledby="invoices-heading">
      <h2 id="invoices-heading">Invoices</h2>
      <VatSwitch />
      <OutstandingTotal invoices={invoices} />
      <InvoiceTable invoices={invoices} />
    </section>
  );
}

Turn on the switch and the switch, the total and the column move in the same render: the header reads “Amount with VAT” and Northwind’s row £480.00. Turn it off in a second tab and this tab follows. Reload, and the page starts from the saved preference.

What a store outside React costs

Handing React a store it doesn’t own buys consistency, and it gives up some of what React can do with state it does own.

No tearing, and no Transitions

React can pause a render partway, and a store that changed during the pause would leave some components showing the old value and some the new. React reads the snapshot again before it commits and starts over if it moved, so every component in one render sees one value. The price is that a store change can’t be a non-blocking Transition. If the store changes during one, React finishes that update as blocking, and a component that suspends on a store value shows its Suspense fallback rather than the old screen.

A snapshot is read on every render

React calls getSnapshot whenever a reader renders, and checks it again before it commits. A localStorage read is synchronous and cheap at this size. Parsing, sorting or filtering is not, so do that once per change and return the cached result.

A switch that says what it does

The control is a native checkbox with role="switch" and a visible label, so it announces as on or off. The amounts don’t rely on their figures alone: the column header and the total say “with VAT” or “before VAT”, for anyone who can’t tell £400.00 from £480.00 at a glance.

Testing two tabs in one

The browser’s side of another tab is a storage event, so the test dispatches one. It also renders the page on the server and hydrates it, to see which value each side uses:

import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, expect, test, vi } from "vitest";
import type { Invoice } from "./invoices";
import { InvoicesPage } from "./invoices-page";
import { useVatPreference } from "./use-vat-preference-before";

const KEY = "billing:amounts-with-vat";
const INVOICES: Invoice[] = [
  {
    id: "1",
    number: "INV-2041",
    customer: "Northwind Ltd",
    net: 40000,
    vat: 8000,
  },
  {
    id: "2",
    number: "INV-2042",
    customer: "Bluebell Bakery",
    net: 9500,
    vat: 1900,
  },
];

afterEach(() => localStorage.clear());

const cell = (invoice: string) =>
  screen.getByRole("row", { name: new RegExp(invoice) });

test("the first hook leaves the other readers behind", async () => {
  function Switch() {
    const [withVat, change] = useVatPreference();
    const toggle = () => change(!withVat);
    return (
      <button role="switch" aria-checked={withVat} onClick={toggle} />
    );
  }
  function Total() {
    return <p>{useVatPreference()[0] ? "With VAT" : "Before VAT"}</p>;
  }
  render(
    <>
      <Switch />
      <Total />
    </>,
  );
  await userEvent.click(screen.getByRole("switch"));
  expect(screen.getByRole("switch")).toBeChecked();
  expect(screen.getByText("Before VAT")).toBeVisible();
});

test("switch, total and table change together", async () => {
  render(<InvoicesPage invoices={INVOICES} />);
  expect(cell("INV-2041")).toHaveTextContent("£400.00");
  await userEvent.click(
    screen.getByRole("switch", { name: "Show amounts with VAT" }),
  );
  expect(screen.getByRole("switch")).toBeChecked();
  expect(cell("INV-2041")).toHaveTextContent("£480.00");
  expect(screen.getByText(/^Outstanding/)).toHaveTextContent(
    "£594.00 with VAT",
  );
  expect(localStorage.getItem(KEY)).toBe("true");
});

test("a change made in another tab reaches this one", async () => {
  render(<InvoicesPage invoices={INVOICES} />);
  // What the browser does when another tab writes the key.
  localStorage.setItem(KEY, "true");
  act(() =>
    window.dispatchEvent(
      new StorageEvent("storage", { key: KEY, newValue: "true" }),
    ),
  );
  expect(screen.getByRole("switch")).toBeChecked();
  expect(cell("INV-2042")).toHaveTextContent("£114.00");
});

test("server HTML is before VAT; hydration reads it", async () => {
  localStorage.setItem(KEY, "true");
  const html = renderToString(<InvoicesPage invoices={INVOICES} />);
  expect(html).toContain("£400.00");
  expect(html).not.toContain("£480.00");
  const failed = vi.spyOn(console, "error");
  const page = document.createElement("div");
  page.innerHTML = html;
  document.body.append(page);
  await act(async () => {
    hydrateRoot(page, <InvoicesPage invoices={INVOICES} />);
  });
  expect(cell("INV-2041")).toHaveTextContent("£480.00");
  expect(failed).not.toHaveBeenCalled();
  page.remove();
  failed.mockRestore();
});

All four pass. The first shows the opening’s fault: with the first hook, the switch turns on and the total stays before VAT.

Server rendering and hydration

The server’s HTML shows amounts before VAT, whatever this browser saved, because getServerSnapshot answers there and during hydration. React then renders again with the browser’s snapshot, without a hydration mismatch, and the fourth test confirms that nothing is logged. People who keep VAT on see the figures change once, just after the page loads. When that flash matters, keep the preference in a cookie, so the server can render the right figures first.

React 18, 19 and the Compiler

useSyncExternalStore arrived in React 18 for concurrent rendering, and it is the same in React 19. Libraries that still support older versions use the use-sync-external-store package’s shim. The React Compiler doesn’t change the rules: subscribe must stay stable and the snapshot must be cached, and module-level functions satisfy both with or without it.

When React should own the value

If React can own the state, let it. A value the app creates and changes itself belongs in useState or useReducer, shared through context when several screens need it; react.dev recommends built-in state wherever it fits. Keep this hook for values that live outside React and change on their own: browser APIs such as storage, media queries and the online status, and stores from other libraries.

Data that arrives once from a request isn’t a store either. Read it as a promise with use() and Suspense, or through a data library.

How Turtlekit’s toasts and Switch use an external store

Turtlekit’s toasts work the same way. toast is an external store, so any function can show a message, inside a component or not, and ToastProvider, placed once around the app, subscribes to it with useSyncExternalStore. With the kit’s Switch, the preference can be changed from anywhere, with a way back:

"use client";
// The VAT switch with Turtlekit. The kit's toast store is an external
// store too: a plain function can show a message, and ToastProvider,
// once around the app, subscribes to it with useSyncExternalStore.
import { Switch, toast } from "turtlekit";
import { setWithVat, useWithVat } from "./vat-preference";

/** Changes the preference from anywhere, with a way back. */
export function changeVat(next: boolean) {
  setWithVat(next);
  toast.neutral(
    next ? "Amounts now include VAT." : "Amounts now exclude VAT.",
    {
      action: { label: "Undo", onClick: () => setWithVat(!next) },
    },
  );
}

export function VatSwitch() {
  return (
    <Switch
      label="Show amounts with VAT"
      checked={useWithVat()}
      onCheckedChange={changeVat}
    />
  );
}

changeVat is a plain function, yet the message appears wherever the provider renders it, and its Undo writes to the same preference store, so every amount on the page follows. A toast with an action stays until it is dismissed, so the Undo can’t vanish while someone reads it. To read a slice of the toast store in your own component, useToastSelector subscribes the same way.

Built on this pattern in Turtlekit: Root and Toast.

When the store and the screen disagree

“The result of getSnapshot should be cached”

The snapshot returns a new object or array on each call. Return a primitive, or cache the object and return the same one until the underlying value changes.

The component resubscribes on every render

subscribe is declared inside the component, so it is a new function each time. Move it to module level, or wrap it in useCallback with the values it depends on.

Other tabs update but this one doesn’t

The tab that writes gets no storage event of its own. Call this tab’s subscribers after every write, as setWithVat does.

Ask the store, don’t remember it

The first hook gave every component its own memory of the preference and trusted them to agree, which they did until the one that changed it forgot to tell the rest. The finished page keeps no memories. The value stays in storage, a single subscribe function reports every change from this tab or another, and the switch, the total and the table ask the same store in the same render, so they show one answer.

Reading context with use() in React 19

When React can own the value, and a provider can hold it.

Context selectors in React

When many readers need only a slice of one shared value.

Custom hooks and hook composition in React

When the subscription should hide behind one name, as useWithVat does.

Reading async data with use() and Suspense in React

When the data arrives once, from a request, rather than changing over time.