Most React apps reach for a state library the moment state has to live outside a component. Building one yourself is the fastest way to understand what useSyncExternalStore actually expects โ and why a store that returns a fresh object on every getSnapshot call loops forever.
๐งฉ Overview
Build the external store behind a live metrics dashboard: immutable snapshots, subscribe/getSnapshot, five-second polling, stale-response protection, and pause/resume with real cleanup โ all in store.ts.
๐ https://www.reactchallenges.com/challenges/build-external-store
โ Requirements
-
createMetricsStore(fetcher)returnssubscribe,getSnapshot,start,refresh,pause, andresume;fetcherdefaults tofetchMetrics. - The initial snapshot is
{ metrics: [], updatedAt: null, status: "idle", error: null }. -
getSnapshotreturns the same object reference until the state changes. -
subscribe(listener)registers a listener and returns a function that removes it. - Subscribers are notified after every state change.
-
start()fetches immediately, then every five seconds, and returns a cleanup that stops polling. - Calling
start()while polling is active does not create a second interval or another fetch. - While a request is in flight and the store is not paused, the status is
loading. -
refresh()ends inlivewith the new metrics andupdatedAt, or inerrorwith the message. - When a request fails, the last successful metrics stay in the snapshot.
- After an error, the next poll retries the request.
- When two requests overlap, only the latest response is applied.
-
pause()stops polling, sets the status topaused, and ignores an in-flight response. -
resume()fetches immediately, restarts polling, and does nothing if the store is not paused. - A manual
refresh()while paused updates the data and keeps the statuspaused. - The
dashboardStoresingleton is exported and used by the providedApp.tsx.
๐ก Notes
- The state lives in a closure outside React, and every update must create a new snapshot object.
getSnapshotis compared withObject.is, so returning a fresh object on every call causes an infinite render loop. - The store is DOM-agnostic: it never reads
document.hidden. The providedApp.tsxcallspause()andresume()when tab visibility changes. - Pausing is not only about clearing the interval. Invalidate the request already in flight, or its response can flip the status back to
liveafter the tab is hidden.
๐งช Tests
- exports the dashboardStore singleton
- renders the provided dashboard with the store
- starts with an idle snapshot and a stable reference
- notifies subscribers when the state changes
- stops notifying after unsubscribing
- fetches immediately when started
- shows a loading status while a request is in flight
- polls every five seconds
- starts only one interval and one fetch when start is called twice
- stops polling after stop is called
- keeps the last metrics when a request fails
- recovers on the next poll after an error
- discards a stale response that arrives after a newer one
- pauses polling and marks the dashboard as paused
- ignores an in-flight response that arrives after pausing
- resumes with an immediate fetch and restarts polling
- does nothing when resumed while already running
- stays paused when refreshed manually while paused
- keeps the snapshot reference stable between updates
By the end you'll know exactly what useSyncExternalStore expects from a store: stable snapshots, safe subscriptions, idempotent polling, and cleanup that survives StrictMode, tab switches, and out-of-order responses.
Top comments (0)