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>
);
}
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.
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?