Build CoreOrdered learning track

External Store Failure Modes

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 066

Failure modes external store di React: tearing, stale snapshot, uncached getSnapshot, selector allocation, resubscribe churn, SSR mismatch, persistence hydration, singleton leakage, hidden global coupling, event loops, dan debugging protocol.

9 min read1795 words
PrevNext
Lesson 66123 lesson track24–67 Build Core
#react#external-store#usesyncexternalstore#state-management+4 more

Part 066 — External Store Failure Modes

External store terlihat sederhana:

state di luar React,
component subscribe,
store berubah,
UI rerender.

Tetapi di React modern, terutama dengan concurrent rendering, SSR, hydration, persistence, selector, dan multi-tab, external store punya kontrak yang tidak boleh dianggap remeh.

Kalau kontrak ini rusak, gejalanya sering tidak langsung:

- row tertentu rerender terus,
- UI kadang membaca state lama,
- hydration warning muncul hanya di production,
- listener leak setelah Strict Mode double mount,
- selector terlihat benar tapi selalu berubah,
- store singleton membawa data user sebelumnya,
- optimistic update hilang setelah rehydrate,
- command dipanggil dua kali,
- tab lain tidak sinkron,
- DevTools tidak membantu karena mutation tidak tercatat.

Part ini adalah katalog failure modes. Bukan untuk menakut-nakuti. Tapi untuk membuat kamu bisa mendesain, mereview, dan men-debug external store seperti engineer production.


1. What Counts as External Store?

External store adalah sumber state yang hidup di luar lifecycle state React component.

Contoh:

- Redux store
- Zustand store
- custom subscribe/getState store
- browser API state seperti online/offline status
- localStorage-backed state
- WebSocket session state
- BroadcastChannel state
- custom event emitter state
- URL/history adapter
- in-memory singleton cache

React component tidak memiliki state itu. Component hanya membaca snapshot dan subscribe ke perubahan.

Mental model:

Kontrak dasarnya:

React harus bisa membaca snapshot yang konsisten.
React harus bisa subscribe dan unsubscribe dengan aman.
Snapshot yang sama harus punya identity stabil.
Perubahan store harus memberitahu React.
Server snapshot harus cocok dengan hydration jika SSR dipakai.

2. The useSyncExternalStore Contract

External store React-safe biasanya masuk melalui:

const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot,
);

Kontrak penting:

subscribe(callback)
  - mendaftarkan callback ke store,
  - memanggil callback saat store berubah,
  - mengembalikan unsubscribe function.

getSnapshot()
  - membaca current snapshot,
  - harus mengembalikan value yang sama secara identity jika data belum berubah,
  - tidak boleh selalu membuat object/array baru.

getServerSnapshot()
  - optional untuk client-only external store,
  - dibutuhkan jika component dirender di server,
  - value awal server harus match value saat hydration di client.

Minimal adapter:

function useStore<T>(selector: (state: StoreState) => T): T {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getInitialServerState()),
  );
}

Masalah: kode minimal ini belum cukup aman jika selector mengalokasikan value baru. Nanti kita bahas.


3. Failure Mode Map

The rest of this part walks through each category.


4. Failure Mode: Uncached Snapshot

Symptom

- Component rerenders repeatedly.
- React warns that getSnapshot result should be cached.
- UI feels stuck in a render loop.

Broken Code

function getSnapshot() {
  return {
    todos: store.todos,
    selectedIds: store.selectedIds,
  };
}

Every call returns a new object. Even if underlying data did not change, identity changes. React cannot know it is semantically same.

Why It Breaks

External store snapshot is part of render input. If the snapshot identity changes every read, React sees new input every time.

Fix

Cache the snapshot and replace it only when store data changes.

type Snapshot = {
  todos: Todo[];
  selectedIds: ReadonlySet<string>;
};

let currentState = {
  todos: [] as Todo[],
  selectedIds: new Set<string>(),
};

let currentSnapshot: Snapshot = {
  todos: currentState.todos,
  selectedIds: currentState.selectedIds,
};

function getSnapshot() {
  return currentSnapshot;
}

function setState(updater: (state: typeof currentState) => typeof currentState) {
  const nextState = updater(currentState);
  if (Object.is(nextState, currentState)) return;

  currentState = nextState;
  currentSnapshot = {
    todos: currentState.todos,
    selectedIds: currentState.selectedIds,
  };

  emitChange();
}

Invariant:

If data did not change, snapshot identity must not change.

Test:

expect(getSnapshot()).toBe(getSnapshot());

5. Failure Mode: Mutable Snapshot

Symptom

- UI sometimes does not update.
- Selector sees changed nested data without store notification.
- Time-travel/debugging impossible.
- Previous state is accidentally modified.

Broken Code

function addTodo(todo: Todo) {
  state.todos.push(todo);
  listeners.forEach((listener) => listener());
}

The array identity stays the same. Selectors depending on todos may not detect change if equality is reference-based.

Fix

Create a new state object and new changed branches.

function addTodo(todo: Todo) {
  state = {
    ...state,
    todos: [...state.todos, todo],
  };
  emitChange();
}

For large stores, structural sharing is the minimum invariant:

Unchanged branches preserve identity.
Changed branches receive new identity.
Root snapshot changes when observable state changes.

Do not mutate snapshots after exposing them. If necessary, freeze in development.

function freezeDev<T>(value: T): T {
  if (process.env.NODE_ENV !== 'production') {
    return Object.freeze(value);
  }
  return value;
}

Freezing is not architecture. It is a guardrail.


6. Failure Mode: Tearing / Inconsistent Read

Symptom

- Two components render different values from the same store during one UI update.
- Derived data does not match base data.
- A concurrent render sees partial update.

Cause

Store update is not atomic, or getSnapshot reads mutable pieces that can change during render.

Broken pattern:

function updateUserAndPermissions(user: User, permissions: Permission[]) {
  store.user = user;
  emitChange();
  store.permissions = permissions;
  emitChange();
}

The UI can observe intermediate state:

new user + old permissions

Fix

Commit related changes atomically.

function updateSession(next: { user: User; permissions: Permission[] }) {
  state = {
    ...state,
    user: next.user,
    permissions: next.permissions,
  };
  emitChange();
}

For invariants:

A store update should publish one coherent snapshot.
Do not emit after partial mutation.
Do not expose mutable internals while update is in progress.

Diagram:

Correct sequence:


7. Failure Mode: Listener Leak

Symptom

- Store has more listeners than mounted components.
- Actions run multiple times.
- Memory grows over time.
- Strict Mode exposes duplicate behavior in development.

Broken Code

function subscribe(callback: () => void) {
  listeners.add(callback);
  // missing unsubscribe
}

Or:

function subscribe(callback: () => void) {
  window.addEventListener('storage', callback);
  return () => {
    window.removeEventListener('storage', () => callback());
  };
}

The remove function uses a different function reference. The listener is never removed.

Fix

Return exact cleanup.

function subscribe(callback: () => void) {
  listeners.add(callback);
  return () => {
    listeners.delete(callback);
  };
}

Browser event version:

function subscribe(callback: () => void) {
  const handler = () => callback();
  window.addEventListener('storage', handler);
  return () => window.removeEventListener('storage', handler);
}

Invariant:

Every subscribe must have exactly one corresponding unsubscribe path.
Cleanup must remove the exact registered listener.

Test:

const unsubscribe = subscribe(listener);
expect(listenerCount()).toBe(1);
unsubscribe();
expect(listenerCount()).toBe(0);

8. Failure Mode: Resubscribe Churn

Symptom

- Component unsubscribes/resubscribes every render.
- Performance degrades with many components.
- Store listeners churn despite no state change.

Cause

subscribe function identity changes because it is defined inline with reactive values.

Broken:

function useOnlineStatus(source: EventTarget) {
  return useSyncExternalStore(
    (callback) => {
      source.addEventListener('online', callback);
      return () => source.removeEventListener('online', callback);
    },
    () => navigator.onLine,
  );
}

This is not always disastrous, but if subscription setup is expensive, it matters.

Fix

Keep subscribe stable when possible.

function subscribeOnline(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);

  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function getOnlineSnapshot() {
  return navigator.onLine;
}

export function useOnlineStatus() {
  return useSyncExternalStore(subscribeOnline, getOnlineSnapshot, () => true);
}

If subscription depends on props, isolate carefully:

function useRoomStore(roomId: string) {
  const store = useMemo(() => getRoomStore(roomId), [roomId]);

  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot,
  );
}

Invariant:

Changing subscription identity should mean the external subscription target truly changed.

9. Failure Mode: Missed Notification

Symptom

- Store state changes, but UI does not update.
- Refresh fixes UI.
- Some actions update UI, others do not.

Broken Code

function setState(partial: Partial<State>) {
  state = { ...state, ...partial };
  // forgot emitChange()
}

Or conditional notification is wrong:

function setState(partial: Partial<State>) {
  const next = { ...state, ...partial };
  if (next === state) return;
  state = next;
  emitChange();
}

next === state is always false because object spread creates new object. But the opposite bug is also common: mutating in place and checking identity.

function setName(name: string) {
  state.user.name = name;
  if (state === previousState) return;
  emitChange();
}

Fix

Centralize update pipeline.

function commit(next: State) {
  if (Object.is(next, state)) return;
  state = next;
  snapshot = next;
  for (const listener of listeners) listener();
}

function update(updater: (state: State) => State) {
  commit(updater(state));
}

Rule:

No action mutates state except through the store commit function.

10. Failure Mode: Selector Allocation

Symptom

- Component rerenders on every store update.
- Selector seems to select same semantic data.
- Memoized child still rerenders.

Broken Code

const view = useStore((state) => ({
  name: state.user.name,
  canEdit: state.permissions.includes('edit'),
}));

The selector returns a new object every time. Without equality function or memoization, identity always changes.

Fix Option 1: Select primitive separately

const name = useStore((state) => state.user.name);
const canEdit = useStore((state) => state.permissions.includes('edit'));

Fix Option 2: Use shallow equality if library supports it

const view = useStore(
  (state) => ({
    name: state.user.name,
    canEdit: state.permissions.includes('edit'),
  }),
  shallow,
);

Fix Option 3: Memoize derived selector outside render

const selectUserView = createSelector(
  [(state: State) => state.user.name, (state: State) => state.permissions],
  (name, permissions) => ({
    name,
    canEdit: permissions.includes('edit'),
  }),
);

Invariant:

Selector output identity should change only when the selected semantic value changes.

Review smell:

Selector returns {}, [], new Set(), new Map(), .filter(), .map() without memo/equality.

11. Failure Mode: Bad Equality Function

Symptom

- UI misses updates.
- Component does not rerender when selected data changed.
- Equality optimization hides mutation bugs.

Broken Code

const user = useStore((state) => state.user, () => true);

This says every previous/next value is equal. UI never updates.

More subtle:

function equalById(a: User, b: User) {
  return a.id === b.id;
}

If name changes but id stays same, UI reading name will not update.

Fix

Equality must match render dependency.

const userLabel = useStore(
  (state) => ({ id: state.user.id, name: state.user.name }),
  shallow,
);

Or select exactly what is rendered:

const userName = useStore((state) => state.user.name);

Invariant:

If rendered output would change, equality must return false.

12. Failure Mode: Over-Subscription

Symptom

- Component rerenders when unrelated store fields change.
- Large pages rerender after small interaction.
- Store update has huge blast radius.

Broken Code

function Toolbar() {
  const state = useWorkspaceStore();
  return <span>{state.selectedIds.length}</span>;
}

The component subscribes to the entire store.

Fix

Subscribe to minimum render dependency.

function Toolbar() {
  const selectedCount = useWorkspaceStore((state) => state.selectedIds.length);
  return <span>{selectedCount}</span>;
}

Review heuristic:

No production component should subscribe to full store unless it truly renders full store.

Debug protocol:

1. Use profiler.
2. Identify component rerender source.
3. Inspect selector width.
4. Narrow selector.
5. Add equality/memoization only after narrowing.

13. Failure Mode: Under-Subscription

Symptom

- Component reads store data but does not update when it changes.
- Action changes state, but UI remains stale.

Broken Code

function UserName() {
  const user = userStore.getState().user;
  return <span>{user.name}</span>;
}

Direct read during render without subscription. React has no idea store changes should rerender this component.

Fix

Use subscription hook.

function UserName() {
  const name = useUserStore((state) => state.user.name);
  return <span>{name}</span>;
}

Direct getState() is acceptable in command/event code:

function SaveButton() {
  const save = useSaveCommand();

  return (
    <button
      onClick={() => {
        const draft = editorStore.getState().draft;
        save(draft);
      }}
    >
      Save
    </button>
  );
}

But render reads must subscribe.

Invariant:

If render output depends on external store state, component must subscribe to that dependency.

14. Failure Mode: Command in Render

Symptom

- Store updates during render.
- Infinite render loop.
- React purity violations.
- Behavior changes under Strict Mode/concurrent rendering.

Broken Code

function Guard({ user }: { user: User | null }) {
  const setUser = useSessionStore((state) => state.setUser);

  if (user) {
    setUser(user);
  }

  return null;
}

Render must be pure. Updating external store during render is still a side effect.

Fix

Move synchronization to effect if it is truly external sync.

function Guard({ user }: { user: User | null }) {
  const setUser = useSessionStore((state) => state.setUser);

  useEffect(() => {
    if (user) setUser(user);
  }, [user, setUser]);

  return null;
}

Better: avoid sync if duplicate source of truth. Maybe the component should read user directly from query/session provider instead of copying into store.

Decision:

If data can be derived from props/query/context, do not mirror it into external store.
If external system truly must be synchronized, use effect with cleanup/idempotency.

15. Failure Mode: Stale Closure in Store Actions

Symptom

- Async action writes stale result.
- Fast repeated actions produce wrong final state.
- Latest request is overwritten by older response.

Broken Code

const useSearchStore = create<SearchState>((set) => ({
  query: '',
  results: [],
  async search(query) {
    set({ query });
    const results = await api.search(query);
    set({ results });
  },
}));

If search('a') returns after search('ab'), old result can overwrite new result.

Fix: Request identity guard

const useSearchStore = create<SearchState>((set, get) => ({
  query: '',
  requestId: null,
  results: [],

  async search(query) {
    const requestId = crypto.randomUUID();
    set({ query, requestId });

    const results = await api.search(query);

    if (get().requestId !== requestId) return;
    set({ results });
  },
}));

Invariant:

Async result may commit only if it still belongs to current request state.

For server-state, prefer query/mutation cache instead of hand-rolled async store unless this is truly client-owned workflow.


16. Failure Mode: Singleton Store Data Leak

Symptom

- User A data appears after User B logs in.
- Tenant switch preserves old selection/preferences.
- Tests influence each other.
- SSR request data leaks across users.

Cause

A singleton store outlives the logical session/tenant/request boundary.

Broken:

export const useCaseStore = create<CaseStore>(() => ({
  selectedCaseIds: [],
  draftByCaseId: {},
}));

If the store is global, it persists until page reload unless explicitly reset.

Fix Option 1: Reset on boundary change

function TenantBoundary({ tenantId, children }: Props) {
  const reset = useCaseStore((state) => state.reset);

  useEffect(() => {
    reset();
  }, [tenantId, reset]);

  return children;
}

Fix Option 2: Scoped store provider

const StoreContext = createContext<StoreApi<CaseStore> | null>(null);

function CaseWorkspaceProvider({ caseId, children }: Props) {
  const storeRef = useRef<StoreApi<CaseStore> | null>(null);

  if (!storeRef.current) {
    storeRef.current = createCaseStore({ caseId });
  }

  return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
}

Invariant:

Store lifetime must not exceed the lifetime of data authority.

For SSR:

Never put per-request user data in module-level singleton store.
Create store per request or per client boundary.

17. Failure Mode: SSR/Hydration Mismatch

Symptom

- Hydration warning.
- UI flashes from default state to persisted state.
- Server rendered markup does not match client initial snapshot.

Broken Code

function getSnapshot() {
  return localStorage.getItem('theme') ?? 'light';
}

function ThemeIcon() {
  const theme = useSyncExternalStore(subscribeTheme, getSnapshot);
  return <span>{theme}</span>;
}

On server, localStorage does not exist. If component is rendered on server, it cannot read client storage.

Fix Option 1: Provide server snapshot

function getServerSnapshot() {
  return 'light';
}

function useTheme() {
  return useSyncExternalStore(subscribeTheme, getThemeSnapshot, getServerSnapshot);
}

But this still may flash if client persisted value is different.

Fix Option 2: Bootstrap initial value into HTML

<script>
  window.__INITIAL_THEME__ = "dark";
</script>

Then server snapshot and initial client snapshot can agree.

Fix Option 3: Client-only boundary

If value cannot be known on server and mismatch is acceptable, defer rendering until client mounted.

function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return children;
}

Use sparingly; it gives up SSR for that subtree.

Invariant:

Server snapshot must equal the first client hydration snapshot for server-rendered content.

18. Failure Mode: Persistence Rehydration Flash

Symptom

- UI starts with default store state then jumps to persisted state.
- Actions happen before rehydrate finishes.
- Persisted schema old version crashes current UI.

Cause

Persistence is asynchronous or not coordinated with app readiness. Even synchronous localStorage can create mismatch if server snapshot differs.

Broken assumption:

Persist middleware means persistence is solved.

Persistence needs policy:

- storage key ownership,
- version,
- migration,
- partialize/whitelist,
- rehydrate status,
- logout cleanup,
- tenant cleanup,
- corrupt data fallback,
- sensitive data exclusion.

Fix: Model hydration explicitly

type StoreState = {
  hydration: 'pending' | 'ready' | 'failed';
  preferences: Preferences;
};

Render boundary:

function PreferencesGate({ children }: { children: React.ReactNode }) {
  const hydration = usePreferencesStore((state) => state.hydration);

  if (hydration === 'pending') return <PreferencesSkeleton />;
  if (hydration === 'failed') return <PreferencesFallback />;

  return children;
}

Invariant:

UI that depends on persisted state must know whether persistence has been rehydrated.

19. Failure Mode: Cross-Tab Inconsistency

Symptom

- User logs out in one tab but another tab still looks authenticated.
- Preferences change in one tab but another tab stays stale.
- Draft conflict appears between tabs.

Cause

Store is memory-local per tab. Persistence alone does not guarantee live synchronization.

Fix Options

Use storage event for localStorage-backed sync:

function subscribePreferences(callback: () => void) {
  const handler = (event: StorageEvent) => {
    if (event.key === 'preferences') callback();
  };

  window.addEventListener('storage', handler);
  return () => window.removeEventListener('storage', handler);
}

Use BroadcastChannel for explicit app events:

const channel = new BroadcastChannel('session');

function broadcastLogout() {
  channel.postMessage({ type: 'logout' });
}

function subscribeSession(callback: () => void) {
  const handler = (event: MessageEvent) => {
    if (event.data?.type === 'logout') callback();
  };

  channel.addEventListener('message', handler);
  return () => channel.removeEventListener('message', handler);
}

Invariant:

If state meaning crosses tab boundary, the store needs cross-tab invalidation or synchronization policy.

Avoid syncing sensitive tokens through browser storage if your security model does not allow it.


20. Failure Mode: Event Loop Between Store and Effect

Symptom

- Effect updates store.
- Store update rerenders component.
- Effect runs again.
- Infinite loop or repeated network calls.

Broken Code

function UserSync({ user }: { user: User }) {
  const session = useSessionStore((state) => state.session);
  const setSession = useSessionStore((state) => state.setSession);

  useEffect(() => {
    setSession({ user });
  }, [user, session, setSession]);

  return null;
}

Effect depends on session, but effect updates session.

Fix

First question: should this sync exist?

If yes, depend only on source values and make update idempotent.

useEffect(() => {
  setSessionFromUser(user);
}, [user, setSessionFromUser]);

Inside store action:

setSessionFromUser(user) {
  const current = get().session.user;
  if (current?.id === user.id && current.version === user.version) return;
  set({ session: { user } });
}

Invariant:

Effects that synchronize external store must be idempotent.
Do not include target state as dependency unless the effect genuinely reacts to it.

21. Failure Mode: Hidden Global Coupling

Symptom

- Component behavior depends on store not visible from props.
- Test setup requires many unrelated stores.
- Reusing component in another page breaks unexpectedly.
- Feature A changes store and Feature B changes behavior silently.

Cause

External store import is a hidden dependency.

function SubmitButton() {
  const canSubmit = useWorkflowStore((state) => state.canSubmit);
  return <button disabled={!canSubmit}>Submit</button>;
}

Maybe okay inside feature. Bad inside reusable design system component.

Fix

Keep external store reads at smart boundary. Pass explicit props to reusable children.

function SubmitButton({ disabled }: { disabled: boolean }) {
  return <button disabled={disabled}>Submit</button>;
}

function SubmitButtonContainer() {
  const canSubmit = useWorkflowStore((state) => state.canSubmit);
  return <SubmitButton disabled={!canSubmit} />;
}

Invariant:

Reusable components should not depend on app-global stores unless that dependency is part of their public contract.

Architecture rule:

Store-aware components belong at feature/application boundary.
Pure/reusable components receive props.

22. Failure Mode: Store as Manual Server Cache

Symptom

- Store has loading/error/data/lastFetchedAt/stale flags for many endpoints.
- Invalidation is inconsistent.
- Duplicate fetches happen.
- Optimistic updates conflict with refetches.
- Cache bugs dominate feature work.

Broken Architecture

type UserStore = {
  users: Record<string, User>;
  loadingById: Record<string, boolean>;
  errorById: Record<string, string | null>;
  lastFetchedAtById: Record<string, number>;
  fetchUser(id: string): Promise<void>;
  invalidateUser(id: string): void;
};

You are building a query cache. Maybe badly.

Fix

Use a server-state cache abstraction:

- TanStack Query
- RTK Query
- framework loader/cache if appropriate

Keep external client store for client-owned state:

selected IDs
open panels
draft-only UI state
workflow state
local preferences

Invariant:

If server is authority, client store should not become long-lived source of truth unless it implements cache lifecycle deliberately.

23. Failure Mode: Unbounded Store Growth

Symptom

- Store memory grows during navigation.
- Old entities never removed.
- Closed workspaces still keep drafts/listeners.
- Performance degrades over long sessions.

Cause

External store has longer lifetime than UI workflows but no eviction/reset policy.

Fix

Define lifecycle explicitly:

- reset on logout,
- reset on tenant switch,
- reset on route leave,
- LRU/TTL for cached client state,
- dispose scoped store on unmount,
- remove entity when workflow finishes.

Example:

function WorkspaceRoute({ workspaceId }: Props) {
  const resetWorkspace = useWorkspaceStore((state) => state.resetWorkspace);

  useEffect(() => {
    return () => resetWorkspace(workspaceId);
  }, [workspaceId, resetWorkspace]);

  return <Workspace />;
}

Invariant:

Every external store must have a lifetime policy.

24. Failure Mode: Overloaded Actions

Symptom

- One store action does validation, API call, navigation, toast, analytics, cache update, and state mutation.
- Tests become integration-heavy.
- Reusing action in another context causes unintended side effects.

Broken Code

async function approveCase(id: string) {
  set({ status: 'submitting' });
  await api.approve(id);
  queryClient.invalidateQueries(['case', id]);
  analytics.track('case_approved');
  toast.success('Approved');
  navigate('/cases');
  set({ status: 'done' });
}

Fix

Separate domain transition, app command, and UI feedback.

Store action:
  transition local workflow state.

Mutation layer:
  call server and invalidate cache.

Orchestration hook:
  combine command, toast, navigation, analytics.

Example:

function useApproveCaseCommand(caseId: string) {
  const markSubmitting = useApprovalStore((s) => s.markSubmitting);
  const markFailed = useApprovalStore((s) => s.markFailed);
  const mutation = useApproveCaseMutation();
  const toast = useToast();
  const navigate = useNavigate();

  return async function approve() {
    markSubmitting(caseId);

    try {
      await mutation.mutateAsync(caseId);
      toast.success('Case approved');
      navigate('/cases');
    } catch (error) {
      markFailed(caseId, toMessage(error));
    }
  };
}

Invariant:

Store action should not become a hidden application service unless that is an explicit architecture decision.

25. Failure Mode: Bad Store Boundary in Tests

Symptom

- Tests pass individually but fail together.
- Store state leaks between tests.
- Tests require manual cleanup everywhere.

Cause

Singleton store persists across tests.

Fix Option 1: Reset after each test

afterEach(() => {
  useSelectionStore.getState().reset();
});

Fix Option 2: Store factory per test

function createTestStore(overrides?: Partial<State>) {
  return createStore<State>(() => ({
    ...initialState,
    ...overrides,
  }));
}

Fix Option 3: Provider-scoped store

render(
  <StoreProvider store={createTestStore()}>
    <Feature />
  </StoreProvider>,
);

Invariant:

Tests must control store initial state and store lifetime.

26. Failure Mode: Action Without Invariant

Symptom

- Store allows impossible states.
- UI has many booleans that conflict.
- Edge cases require if statements everywhere.

Broken:

type UploadState = {
  isIdle: boolean;
  isUploading: boolean;
  isSuccess: boolean;
  isError: boolean;
  error?: string;
};

This allows:

isUploading=true and isSuccess=true
isIdle=true and error present
isError=false and error present

Fix:

type UploadState =
  | { status: 'idle' }
  | { status: 'uploading'; requestId: string }
  | { status: 'success'; fileId: string }
  | { status: 'error'; message: string };

Invariant:

State shape should make illegal states impossible or at least hard to represent.

External store does not remove the need for reducer/state-machine thinking. It amplifies it because more components can depend on the state.


27. Failure Mode: DevTools Illusion

Symptom

- DevTools show state changed, but not why.
- Timeline misses side effects.
- Store mutation happened outside named action.

Zustand/Redux/Custom store observability differs. Do not assume DevTools solves architecture.

Good observability records:

- command/action name,
- payload summary,
- previous relevant state,
- next relevant state,
- request ID,
- user/session/tenant boundary when allowed,
- error outcome,
- duration for async command.

For custom/Zustand store, consider action wrapper:

function namedSet(actionName: string, updater: (state: State) => State) {
  const prev = state;
  const next = updater(prev);
  state = next;
  logStoreTransition({ actionName, prev, next });
  emitChange();
}

Invariant:

If external store state matters for debugging, mutations need names.

This is one reason Redux Toolkit remains valuable in large governed systems.


28. Debugging Protocol for External Store Bugs

When a bug appears, do not randomly add memoization. Use a protocol.

Step 1 — Classify the symptom

Does UI fail to update?
Does UI update too much?
Does UI show impossible state?
Does issue happen only SSR/hydration?
Does issue happen after navigation/logout/tenant switch?
Does issue happen after async race?
Does issue happen across tabs?

Step 2 — Identify subscription width

Which selector does the component use?
Does it subscribe to full store?
Does selector allocate new object/array?
Does equality match rendered dependency?

Step 3 — Inspect mutation path

Is state mutated in place?
Is emitChange called exactly once after atomic commit?
Does action publish coherent snapshot?
Is async result guarded by request ID?

Step 4 — Inspect lifetime

Is store singleton or scoped?
When does it reset?
Does it leak across users/tests/routes?
Does SSR create per-request state?

Step 5 — Inspect external boundaries

Does store mirror server data?
Does persisted state have version/migration?
Does cross-tab state sync matter?
Does effect create update loop?

Step 6 — Add minimal fix

Narrow selector before adding memoization.
Fix snapshot identity before adding equality hacks.
Fix ownership before adding reset effects.
Use query cache before rebuilding server cache.
Use scoped store before adding global cleanup hacks.

29. Production Checklist

Before approving an external store, verify:

[ ] State category is client-owned, not secretly server-owned.
[ ] Store lifetime is defined.
[ ] Reset policy exists for logout/tenant/route/workspace if relevant.
[ ] Snapshot identity is stable when data does not change.
[ ] Updates are atomic and publish coherent snapshots.
[ ] Subscriptions return correct cleanup.
[ ] Render reads subscribe; event-only reads may use getState.
[ ] Selectors are narrow.
[ ] Selectors do not allocate unstable values without equality/memoization.
[ ] Equality function matches rendered dependency.
[ ] Async actions guard stale results.
[ ] Persistence has version, migration, whitelist, and corrupt-data fallback.
[ ] SSR/hydration snapshot policy is explicit.
[ ] Cross-tab sync policy exists if state crosses tab boundary.
[ ] Store mutations have names if debugging/audit matters.
[ ] Reusable components do not import app-global stores accidentally.
[ ] Tests can create/reset store deterministically.

30. Review Smells

Red flags:

const state = useStore();

Unless component truly renders entire store, this is over-subscription.

useStore((s) => ({ a: s.a, b: s.b }))

Without equality/memoization, this may allocate every update.

store.getState() inside render

Render read without subscription.

setStore(...) inside component body

Render side effect.

localStorage read during server render

Hydration risk.

create(...) at module level with request/session data

Lifetime leak risk.

state.serverUsers = response.users

Manual server cache smell.

AppStore contains auth, theme, filters, form drafts, notifications, workflow, API cache

State taxonomy failure.


31. Build a Safer External Store Skeleton

A minimal safer store skeleton:

type Listener = () => void;

type StoreApi<State> = {
  getSnapshot(): State;
  subscribe(listener: Listener): () => void;
  update(actionName: string, updater: (state: State) => State): void;
  reset(): void;
};

export function createExternalStore<State>(initialState: State): StoreApi<State> {
  let state = initialState;
  const listeners = new Set<Listener>();

  function emit() {
    for (const listener of Array.from(listeners)) {
      listener();
    }
  }

  return {
    getSnapshot() {
      return state;
    },

    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },

    update(actionName, updater) {
      const next = updater(state);
      if (Object.is(next, state)) return;

      const previous = state;
      state = next;

      if (process.env.NODE_ENV !== 'production') {
        console.debug('[store]', actionName, { previous, next });
      }

      emit();
    },

    reset() {
      if (Object.is(state, initialState)) return;
      state = initialState;
      emit();
    },
  };
}

React adapter:

function useExternalSelector<State, Selected>(
  store: StoreApi<State>,
  selector: (state: State) => Selected,
): Selected {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getSnapshot()),
    () => selector(store.getSnapshot()),
  );
}

This adapter still has selector allocation caveat. To make it production-grade, add selector caching/equality or use a library that already solved this carefully.

Skeleton lesson:

A store is not just a variable.
A store is a contract: snapshot, subscription, atomic commit, lifetime, and observability.

32. External Store vs Context Failure Mode Difference

FailureContextExternal Store
Rerender fan-outProvider value change rerenders consumersOver-wide selectors rerender subscribers
Hidden dependencyConsumer hidden under providerModule-level store import hidden globally
Snapshot identityContext value identitygetSnapshot/selector identity
CleanupProvider effectssubscribe/unsubscribe
LifetimeProvider subtreeStore singleton/scoped lifetime
SSRProvider initial valuegetServerSnapshot/hydration
DebuggingTree/provider inspectionStore/action/subscription inspection

This matters because a refactor from Context to external store does not delete failure modes. It changes them.


33. External Store vs Redux Toolkit Failure Mode Difference

Redux Toolkit is also an external store pattern, but with stronger conventions.

Custom/Zustand-style stores often fail through:

- hidden mutation,
- inconsistent action naming,
- selector instability,
- lifetime leakage,
- weak observability.

Redux Toolkit often fails through:

- putting too much local state globally,
- manual server cache in slices,
- action boilerplate for trivial state,
- selector overengineering,
- normalized state without ownership clarity.

Different tools, different failure modes.

Top-tier engineering means choosing which failure mode you are willing and prepared to manage.


34. Exercises

Exercise 1 — Find Selector Smells

Audit a page that uses Zustand/Redux/custom store. Find all selectors that:

- return whole store,
- return new object,
- return filtered/mapped array,
- use equality incorrectly,
- read store directly in render.

Refactor one component to subscribe to the smallest possible value.


Exercise 2 — Add Lifetime Policy

Pick one external store and document:

- when it is created,
- who owns it,
- when it resets,
- whether it survives route change,
- whether it survives logout,
- whether it survives tenant switch,
- whether it survives tests,
- whether it is safe for SSR.

Then implement the missing reset/dispose path.


Exercise 3 — Race Guard

Create an async store action that fetches search results. Then add:

- requestId,
- cancellation or ignore-stale-result guard,
- pending/error state,
- test for out-of-order response.

Prove old response cannot overwrite new response.


Exercise 4 — Persistence Migration

Persist a preferences store with version 1. Then change schema to version 2. Implement:

- migration,
- corrupt data fallback,
- logout cleanup,
- hydration-ready state.

35. Key Takeaways

External store is not just global state.
It is a contract between React render and non-React state.

getSnapshot must be stable.
subscribe must clean up.
updates must be atomic.
selectors must be narrow and stable.
store lifetime must be explicit.
SSR/hydration must have a snapshot policy.
persistence must have versioning and readiness.
server data should use server-state cache.
reusable components should avoid hidden global store coupling.

If Part 065 taught you how to choose an external store, Part 066 teaches you how not to get cut by it.

The sharp edge is not the library. The sharp edge is violating the contract between ownership, subscription, snapshot, and lifecycle.

Lesson Recap

You just completed lesson 66 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.