← Back to question bank
React AppMidMedium#4013 · 40m

Implement external store subscription in a product component

Use external store subscription to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

External-store subscription is the question that asks whether you can integrate non-React state without tearing. The right answer in React 18+ is useSyncExternalStore: it guarantees that all consumers in the same render see the same snapshot, even under concurrent features like startTransition, and it gives SSR a deterministic seed via getServerSnapshot. Manual useState + useEffect equivalents drift on concurrent renders and produce torn UI.

Three contracts the store must honor. subscribe(listener) returns an unsubscribe function and notifies on every meaningful change. getSnapshot returns the current value referentially (returning a new object every call breaks the equality check and causes infinite renders). getServerSnapshot returns a server-safe default — never read from window or localStorage there. The same store can serve any number of consumers; React calls getSnapshot once per render and uses Object.is to decide whether to re-render.

Adjacent traps: emitting on every set call even when the value is unchanged (causes pointless re-renders), returning a new array/object from getSnapshot (use a memoized selector or split the store), and forgetting to dispose subscriptions on unmount (the cleanup returned from subscribe handles it, but custom adapters often drop it). The reference store treats theme as a primitive so referential equality is automatic.

Reference Implementation: useSyncExternalStore Theme Manager

A theme store with subscribe, getSnapshot, and getServerSnapshot wired through React.useSyncExternalStore — tear-free across consumers.

// useSyncExternalStore is the right primitive for any source of truth that
// lives outside React: localStorage, a redux/zustand store, browser media
// query lists, websockets, or a custom theme manager. It guarantees no
// tearing under concurrent rendering and a stable getServerSnapshot for SSR.

type Theme = 'light' | 'dark';

function createThemeStore() {
  let theme: Theme = typeof window === 'undefined'
    ? 'light'
    : (window.localStorage.getItem('theme') as Theme) ?? 'light';
  const listeners = new Set<() => void>();

  function emit() {
    listeners.forEach((listener) => listener());
  }

  return {
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    getSnapshot() {
      return theme;
    },
    getServerSnapshot(): Theme {
      // SSR has no localStorage; render with the server-safe default and
      // let the client hydrate to its actual value via subscribe.
      return 'light';
    },
    set(next: Theme) {
      if (theme === next) return;
      theme = next;
      if (typeof window !== 'undefined') window.localStorage.setItem('theme', next);
      emit();
    },
  };
}

export const themeStore = createThemeStore();

export function useTheme() {
  return React.useSyncExternalStore(
    themeStore.subscribe,
    themeStore.getSnapshot,
    themeStore.getServerSnapshot
  );
}

export function ThemeToggle() {
  const theme = useTheme();
  return (
    <button onClick={()=> themeStore.set(theme= 'light' ? 'dark' : 'light')}>
      Switch to {theme === 'light' ? 'dark' : 'light'}
    </button>
  );
}

Runnable Playground

Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.


// useSyncExternalStore is the right primitive for any source of truth that
// lives outside React: localStorage, a redux/zustand store, browser media
// query lists, websockets, or a custom theme manager. It guarantees no
// tearing under concurrent rendering and a stable getServerSnapshot for SSR.

type Theme = 'light' | 'dark';

function createThemeStore() {
  let theme: Theme = typeof window === 'undefined'
    ? 'light'
    : (window.localStorage.getItem('theme') as Theme) ?? 'light';
  const listeners = new Set<() => void>();

  function emit() {
    listeners.forEach((listener) => listener());
  }

  return {
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    getSnapshot() {
      return theme;
    },
    getServerSnapshot(): Theme {
      // SSR has no localStorage; render with the server-safe default and
      // let the client hydrate to its actual value via subscribe.
      return 'light';
    },
    set(next: Theme) {
      if (theme === next) return;
      theme = next;
      if (typeof window !== 'undefined') window.localStorage.setItem('theme', next);
      emit();
    },
  };
}

export const themeStore = createThemeStore();

export function useTheme() {
  return React.useSyncExternalStore(
    themeStore.subscribe,
    themeStore.getSnapshot,
    themeStore.getServerSnapshot
  );
}

export function ThemeToggle() {
  const theme = useTheme();
  return (
    <button onClick={() => themeStore.set(theme === 'light' ? 'dark' : 'light')}>
      Switch to {theme === 'light' ? 'dark' : 'light'}
    </button>
  );
}
TypeScript · runnable

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Subscription contract
Render once and assert subscribe was called exactly once. Trigger a store update and assert the component re-renders with the new snapshot. Unmount and assert the unsubscribe function fired.
No tearing
Render two consumers in the same tree; update the store inside an act block; assert both consumers report the same value. Without useSyncExternalStore, two useEffect-based subscribers can land in different commits.
SSR snapshot
Render via renderToString and assert the markup uses the server snapshot. Then hydrate and assert React replaces it with the client snapshot without console warnings.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';

describe('useTheme + ThemeToggle', () => {
  beforeEach(() => {
    window.localStorage.clear();
    themeStore.set('light');
  });

  it('subscribes once and re-renders on store change', () => {
    const subscribeSpy = vi.spyOn(themeStore, 'subscribe');
    render(<ThemeToggle />);
    expect(subscribeSpy).toHaveBeenCalledTimes(1);
    expect(screen.getByRole('button')).toHaveTextContent('Switch to dark');

    fireEvent.click(screen.getByRole('button'));
    expect(screen.getByRole('button')).toHaveTextContent('Switch to light');
  });

  it('multiple consumers stay consistent (no tearing)', () => {
    function Twin() {
      const a = useTheme();
      const b = useTheme();
      return <span data-testid="twin">{a === b ? 'sync' : 'torn'}</span>;
    }
    render(<Twin />);
    act(() => themeStore.set('dark'));
    expect(screen.getByTestId('twin')).toHaveTextContent('sync');
  });
});

Interviewer Signal

Shows whether you can explain React behavior while building maintainable product UI.

Constraints

  • Name what is render-derived and what is stored state.
  • Keep side effects owned by events or effects deliberately.
  • Provide a testable boundary for business logic.

Model Answer Shape

  • Start from the user workflow and state ownership.
  • Move pure decisions out of JSX when the branch logic grows.
  • Use React primitives to express ownership, not to hide unclear state.

Tradeoffs

  • Colocating state improves clarity until sibling coordination becomes the real problem.
  • Memoization helps only after render cost or identity churn is measured.

Edge Cases

  • Strict Mode re-running development effects.
  • Stale closures after async work resolves.
  • Unmounts and route changes during in-flight operations.

Testing And Proof

  • Reducer or pure function test for core state transitions.
  • Interaction test for the user workflow.
  • Regression case for stale or repeated async behavior.

Follow-Ups

  • How would this change with server rendering?
  • Where would you place this state in a larger app?