ilokesto

React

Connecting @ilokesto/store to React with useSyncExternalStore.

React

React exposes useSyncExternalStore as the official way to subscribe to external state. It handles concurrent rendering, tearing prevention, and server-side rendering in one call. Every @ilokesto/store adapter in React should be built on top of it.

Adapter

The minimal adapter reads the current snapshot, subscribes to changes, and returns a tearing-free value.

import { useSyncExternalStore } from "react";
import { Store } from "@ilokesto/store";

export function useStore<T>(store: Store<T>): T {
  return useSyncExternalStore(
    store.subscribe,
    store.getState,
    store.getState,
  );
}

Pass store.subscribe directly — useSyncExternalStore passes the listener to it and expects the returned unsubscribe function back, which matches the subscribe signature exactly.

The third argument is the server snapshot. On the server it is read once; on the client it seeds the initial hydration check. Passing store.getState for both means the client and server snapshots are always the same reference, which is appropriate when the store is initialised with the same value on both sides.

Selector pattern

Reading the whole state object is fine for small stores. For larger stores, a selector hook avoids re-renders when unrelated fields change.

import { useSyncExternalStore } from "react";
import { Store } from "@ilokesto/store";

export function useSelector<T, U>(
  store: Store<T>,
  selector: (state: T) => U,
): U {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getState()),
  );
}

useSyncExternalStore compares the return value with Object.is between renders. If the selected slice did not change, the component does not re-render.

// Only re-renders when the count field changes.
const count = useSelector(counterStore, (s) => s.count);

When the selector returns a new object on every call, use useRef to memoize it or stabilise the selector itself with useCallback or a module-level function:

// Stable reference — no extra memoisation needed.
const selectCount = (s: CounterState) => s.count;

function Counter() {
  const count = useSelector(counterStore, selectCount);
  return <span>{count}</span>;
}

Multiple stores

Compose multiple useSelector calls when a component needs state from more than one store. There is no restriction on how many external stores a component may subscribe to.

function Header() {
  const user = useSelector(authStore, (s) => s.user);
  const theme = useStore(themeStore);

  return (
    <header data-theme={theme}>
      {user ? <span>{user.name}</span> : <span>Sign in</span>}
    </header>
  );
}

Each subscription is independent. A change in themeStore does not cause authStore to re-run its selector.

SSR and hydration

When rendering on the server, useSyncExternalStore calls the server snapshot getter instead of getState. Initialise the store with server-side data before the render and the snapshot will be consistent on both sides.

// server.ts
import { counterStore } from "./stores/counter";

export async function renderApp(req: Request): Promise<string> {
  const serverCount = await fetchCountFromDb(req);
  counterStore.setState({ count: serverCount });

  return renderToString(<App />);
}

On the client, hydrate with the same initial value before mounting:

// client.ts
import { counterStore } from "./stores/counter";

const serverData = (window as any).__INITIAL_STATE__;

if (serverData) {
  counterStore.setState(serverData.counter);
}

hydrateRoot(document.getElementById("root")!, <App />);

If the client snapshot passed to useSyncExternalStore does not match the server snapshot, React will log a hydration mismatch warning. Keep both sides consistent to avoid it.

TypeScript

Type-safe selectors follow naturally from the generic signature. The return type is inferred from the selector.

import { Store } from "@ilokesto/store";

type AuthState = {
  user: { id: string; name: string } | null;
  status: "anonymous" | "signed-in";
};

export const authStore = new Store<AuthState>({
  user: null,
  status: "anonymous",
});

// Return type is { id: string; name: string } | null — inferred automatically.
const user = useSelector(authStore, (s) => s.user);

Wrap the hook in a dedicated typed helper when you want to hide the store reference from component code:

export function useAuthUser() {
  return useSelector(authStore, (s) => s.user);
}

export function useAuthStatus() {
  return useSelector(authStore, (s) => s.status);
}

Testing

Test hooks in isolation using renderHook from @testing-library/react. Create a fresh store per test to avoid shared state.

import { renderHook, act } from "@testing-library/react";
import { expect, test } from "vitest";
import { Store } from "@ilokesto/store";
import { useStore } from "./useStore";

test("returns initial state", () => {
  const store = new Store({ count: 0 });

  const { result } = renderHook(() => useStore(store));

  expect(result.current.count).toBe(0);
});

test("re-renders when state changes", () => {
  const store = new Store({ count: 0 });

  const { result } = renderHook(() => useStore(store));

  act(() => {
    store.setState({ count: 5 });
  });

  expect(result.current.count).toBe(5);
});

test("selector limits re-renders to the selected slice", () => {
  const store = new Store({ count: 0, label: "counter" });
  let renderCount = 0;

  const { result } = renderHook(() => {
    renderCount += 1;
    return useSelector(store, (s) => s.label);
  });

  act(() => {
    // count changes, label does not.
    store.setState((prev) => ({ ...prev, count: 1 }));
  });

  expect(result.current).toBe("counter");
  expect(renderCount).toBe(1); // No extra render triggered.
});

Wrap state changes in act so React flushes effects and re-render batches before assertions run.

On this page