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.