Back to Blog
ReactHooksuseSyncExternalStoreState Management

React useSyncExternalStore Hook Guide

Master the React useSyncExternalStore hook for subscribing to external stores with concurrent rendering support.

B
Bootspring Team
Engineering
December 5, 2019
7 min read

The useSyncExternalStore hook subscribes to external data sources with concurrent rendering support. Here's how to use it.

Basic Usage#

1import { useSyncExternalStore } from 'react'; 2 3// External store 4let count = 0; 5const listeners = new Set<() => void>(); 6 7const store = { 8 getSnapshot() { 9 return count; 10 }, 11 12 subscribe(listener: () => void) { 13 listeners.add(listener); 14 return () => listeners.delete(listener); 15 }, 16 17 increment() { 18 count++; 19 listeners.forEach((l) => l()); 20 }, 21}; 22 23// Component using the store 24function Counter() { 25 const count = useSyncExternalStore( 26 store.subscribe, 27 store.getSnapshot 28 ); 29 30 return ( 31 <div> 32 <p>Count: {count}</p> 33 <button onClick={store.increment}>Increment</button> 34 </div> 35 ); 36}

Creating a Store#

1// Generic store creator 2function createStore<T>(initialState: T) { 3 let state = initialState; 4 const listeners = new Set<() => void>(); 5 6 return { 7 getSnapshot() { 8 return state; 9 }, 10 11 subscribe(listener: () => void) { 12 listeners.add(listener); 13 return () => listeners.delete(listener); 14 }, 15 16 setState(newState: T | ((prev: T) => T)) { 17 state = typeof newState === 'function' 18 ? (newState as (prev: T) => T)(state) 19 : newState; 20 listeners.forEach((l) => l()); 21 }, 22 23 getState() { 24 return state; 25 }, 26 }; 27} 28 29// Usage 30const todoStore = createStore({ 31 todos: [] as Todo[], 32 filter: 'all' as Filter, 33}); 34 35function TodoList() { 36 const { todos, filter } = useSyncExternalStore( 37 todoStore.subscribe, 38 todoStore.getSnapshot 39 ); 40 41 const filteredTodos = todos.filter((todo) => { 42 if (filter === 'active') return !todo.completed; 43 if (filter === 'completed') return todo.completed; 44 return true; 45 }); 46 47 return ( 48 <ul> 49 {filteredTodos.map((todo) => ( 50 <li key={todo.id}>{todo.text}</li> 51 ))} 52 </ul> 53 ); 54}

Server-Side Rendering#

1import { useSyncExternalStore } from 'react'; 2 3// Third parameter for SSR 4function useWindowWidth() { 5 return useSyncExternalStore( 6 (callback) => { 7 window.addEventListener('resize', callback); 8 return () => window.removeEventListener('resize', callback); 9 }, 10 () => window.innerWidth, 11 () => 1024 // Server snapshot (fallback) 12 ); 13} 14 15function ResponsiveComponent() { 16 const width = useWindowWidth(); 17 18 return ( 19 <div> 20 {width < 768 ? <MobileView /> : <DesktopView />} 21 </div> 22 ); 23} 24 25// Online status with SSR 26function useOnlineStatus() { 27 return useSyncExternalStore( 28 (callback) => { 29 window.addEventListener('online', callback); 30 window.addEventListener('offline', callback); 31 return () => { 32 window.removeEventListener('online', callback); 33 window.removeEventListener('offline', callback); 34 }; 35 }, 36 () => navigator.onLine, 37 () => true // Assume online on server 38 ); 39}

Selecting Store Data#

1// Store with selectors 2interface AppState { 3 user: User | null; 4 posts: Post[]; 5 settings: Settings; 6} 7 8const appStore = createStore<AppState>({ 9 user: null, 10 posts: [], 11 settings: { theme: 'light' }, 12}); 13 14// Custom hook with selector 15function useStore<T>(selector: (state: AppState) => T): T { 16 return useSyncExternalStore( 17 appStore.subscribe, 18 () => selector(appStore.getSnapshot()) 19 ); 20} 21 22// Component only re-renders when selected data changes 23function UserProfile() { 24 const user = useStore((state) => state.user); 25 26 if (!user) return <p>Not logged in</p>; 27 return <p>Hello, {user.name}</p>; 28} 29 30function PostCount() { 31 const count = useStore((state) => state.posts.length); 32 return <p>{count} posts</p>; 33} 34 35// Memoized selector for derived data 36function useFilteredPosts(filter: string) { 37 return useStore((state) => 38 state.posts.filter((post) => post.category === filter) 39 ); 40}

Browser APIs#

1// localStorage sync 2function useLocalStorage<T>(key: string, initialValue: T) { 3 const subscribe = (callback: () => void) => { 4 window.addEventListener('storage', callback); 5 return () => window.removeEventListener('storage', callback); 6 }; 7 8 const getSnapshot = () => { 9 const item = localStorage.getItem(key); 10 return item ? JSON.parse(item) : initialValue; 11 }; 12 13 const getServerSnapshot = () => initialValue; 14 15 const value = useSyncExternalStore( 16 subscribe, 17 getSnapshot, 18 getServerSnapshot 19 ); 20 21 const setValue = (newValue: T | ((prev: T) => T)) => { 22 const resolvedValue = 23 typeof newValue === 'function' 24 ? (newValue as (prev: T) => T)(value) 25 : newValue; 26 27 localStorage.setItem(key, JSON.stringify(resolvedValue)); 28 window.dispatchEvent(new Event('storage')); 29 }; 30 31 return [value, setValue] as const; 32} 33 34// Media query hook 35function useMediaQuery(query: string) { 36 const subscribe = (callback: () => void) => { 37 const mql = window.matchMedia(query); 38 mql.addEventListener('change', callback); 39 return () => mql.removeEventListener('change', callback); 40 }; 41 42 const getSnapshot = () => window.matchMedia(query).matches; 43 const getServerSnapshot = () => false; 44 45 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); 46} 47 48// Usage 49function Component() { 50 const isDark = useMediaQuery('(prefers-color-scheme: dark)'); 51 const isMobile = useMediaQuery('(max-width: 768px)'); 52 53 return <div className={isDark ? 'dark' : 'light'} />; 54}

History/Router Integration#

1// Simple router using history API 2const historyStore = { 3 subscribe(callback: () => void) { 4 window.addEventListener('popstate', callback); 5 return () => window.removeEventListener('popstate', callback); 6 }, 7 8 getSnapshot() { 9 return window.location.pathname; 10 }, 11 12 getServerSnapshot() { 13 return '/'; 14 }, 15 16 push(path: string) { 17 window.history.pushState(null, '', path); 18 window.dispatchEvent(new PopStateEvent('popstate')); 19 }, 20}; 21 22function useLocation() { 23 return useSyncExternalStore( 24 historyStore.subscribe, 25 historyStore.getSnapshot, 26 historyStore.getServerSnapshot 27 ); 28} 29 30function Router({ routes }: { routes: Record<string, React.ComponentType> }) { 31 const path = useLocation(); 32 const Component = routes[path] || routes['/404']; 33 34 return <Component />; 35} 36 37// Link component 38function Link({ to, children }: { to: string; children: React.ReactNode }) { 39 const handleClick = (e: React.MouseEvent) => { 40 e.preventDefault(); 41 historyStore.push(to); 42 }; 43 44 return ( 45 <a href={to} onClick={handleClick}> 46 {children} 47 </a> 48 ); 49}

WebSocket Connection#

1// WebSocket store 2function createWebSocketStore(url: string) { 3 let socket: WebSocket | null = null; 4 let messages: string[] = []; 5 let status: 'connecting' | 'open' | 'closed' = 'connecting'; 6 const listeners = new Set<() => void>(); 7 8 const connect = () => { 9 socket = new WebSocket(url); 10 11 socket.onopen = () => { 12 status = 'open'; 13 notify(); 14 }; 15 16 socket.onmessage = (event) => { 17 messages = [...messages, event.data]; 18 notify(); 19 }; 20 21 socket.onclose = () => { 22 status = 'closed'; 23 notify(); 24 }; 25 }; 26 27 const notify = () => { 28 listeners.forEach((l) => l()); 29 }; 30 31 connect(); 32 33 return { 34 subscribe(listener: () => void) { 35 listeners.add(listener); 36 return () => listeners.delete(listener); 37 }, 38 39 getSnapshot() { 40 return { messages, status }; 41 }, 42 43 send(message: string) { 44 socket?.send(message); 45 }, 46 47 reconnect() { 48 socket?.close(); 49 connect(); 50 }, 51 }; 52} 53 54// Hook 55function useWebSocket(url: string) { 56 const storeRef = useRef<ReturnType<typeof createWebSocketStore>>(); 57 58 if (!storeRef.current) { 59 storeRef.current = createWebSocketStore(url); 60 } 61 62 const { messages, status } = useSyncExternalStore( 63 storeRef.current.subscribe, 64 storeRef.current.getSnapshot 65 ); 66 67 return { 68 messages, 69 status, 70 send: storeRef.current.send, 71 reconnect: storeRef.current.reconnect, 72 }; 73}

Redux-like Store#

1type Action = { type: string; payload?: any }; 2type Reducer<S> = (state: S, action: Action) => S; 3 4function createReduxStore<S>(reducer: Reducer<S>, initialState: S) { 5 let state = initialState; 6 const listeners = new Set<() => void>(); 7 8 return { 9 subscribe(listener: () => void) { 10 listeners.add(listener); 11 return () => listeners.delete(listener); 12 }, 13 14 getSnapshot() { 15 return state; 16 }, 17 18 dispatch(action: Action) { 19 state = reducer(state, action); 20 listeners.forEach((l) => l()); 21 }, 22 }; 23} 24 25// Reducer 26const counterReducer = (state: number, action: Action) => { 27 switch (action.type) { 28 case 'INCREMENT': 29 return state + 1; 30 case 'DECREMENT': 31 return state - 1; 32 case 'SET': 33 return action.payload; 34 default: 35 return state; 36 } 37}; 38 39const store = createReduxStore(counterReducer, 0); 40 41// Hook 42function useSelector<T>(selector: (state: number) => T) { 43 return useSyncExternalStore( 44 store.subscribe, 45 () => selector(store.getSnapshot()) 46 ); 47} 48 49function useDispatch() { 50 return store.dispatch; 51} 52 53// Component 54function Counter() { 55 const count = useSelector((s) => s); 56 const dispatch = useDispatch(); 57 58 return ( 59 <div> 60 <p>{count}</p> 61 <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button> 62 <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button> 63 </div> 64 ); 65}

Best Practices#

Usage: ✓ Use for external data sources ✓ Provide server snapshot for SSR ✓ Memoize selectors ✓ Keep snapshots immutable Store Design: ✓ Return same object if unchanged ✓ Notify only on actual changes ✓ Clean up subscriptions ✓ Handle edge cases Performance: ✓ Select minimal data needed ✓ Avoid creating objects in getSnapshot ✓ Use stable selector references ✓ Batch updates when possible Avoid: ✗ Using for React-managed state ✗ Mutating snapshot data ✗ Expensive getSnapshot logic ✗ Missing server snapshot

Conclusion#

The useSyncExternalStore hook safely integrates external data sources with React's concurrent rendering. Use it for browser APIs, WebSocket connections, global stores, and third-party state management. Always provide a server snapshot for SSR compatibility and use selectors to optimize re-renders.

Share this article

Help spread the word about Bootspring