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.
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
| Failure | Context | External Store |
|---|---|---|
| Rerender fan-out | Provider value change rerenders consumers | Over-wide selectors rerender subscribers |
| Hidden dependency | Consumer hidden under provider | Module-level store import hidden globally |
| Snapshot identity | Context value identity | getSnapshot/selector identity |
| Cleanup | Provider effects | subscribe/unsubscribe |
| Lifetime | Provider subtree | Store singleton/scoped lifetime |
| SSR | Provider initial value | getServerSnapshot/hydration |
| Debugging | Tree/provider inspection | Store/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.
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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.