Mastering useSyncExternalStore: Sync External State in React
Learn how to use the React Hook useSyncExternalStore to efficiently synchronize React state with external data sources, simplifying code and improving performance. This guide covers practical examples and best practices for managing external state.
Introduction

useSyncExternalStore is a React Hook that lets you subscribe to an external store, providing a robust and efficient way to synchronize React state with data managed outside of React. This hook drastically cleans up code by eliminating the need for complex useEffect implementations when dealing with external, mutable state.
Configuration Checklist
| Element | Version / Link |
|---|---|
| Language / Runtime | JavaScript / TypeScript, Node.js |
| Main library | React (v18+) |
| Required APIs | useRef, useSyncExternalStore (from react), window.addEventListener, window.removeEventListener, navigator.online, HTMLDialogElement |
| Keys / credentials needed | None |
Step 1 — Tracking Online Status
To synchronize React state with an external browser API like navigator.online, which changes asynchronously, useSyncExternalStore offers a cleaner alternative to useEffect. This avoids boilerplate code for event listener setup and cleanup, and prevents potential desynchronization issues.
import { useSyncExternalStore } from "react";
// Define the subscribe function for the external store
// This function takes a callback and registers it to be called when the external state changes.
function subscribe(callback: () => void): () => void {
window.addEventListener("online", callback); // Listen for online events
window.addEventListener("offline", callback); // Listen for offline events
// Return a cleanup function to remove event listeners when the component unmounts
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
// Define the getSnapshot function to retrieve the current state of the external store
// This function should return the current value of the external state.
function getSnapshot(): boolean {
return navigator.online; // Get the current online status from the browser
}
// Optional: getServerSnapshot for server-side rendering (SSR)
// This function provides an initial state for server rendering, as browser APIs won't exist on the server.
// It should return the initial state that the server will render.
function getServerSnapshot(): boolean {
return true; // Assume online by default for server rendering
}
export default function App() {
// Use useSyncExternalStore to get the synchronized online status
// It takes the subscribe, getSnapshot, and optional getServerSnapshot functions.
const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return (
<h1 style={{ color: isOnline ? undefined : "red" }}>
{isOnline ? "Online" : "Offline"}
</h1>
);
}
Step 2 — Syncing HTML Dialog Element State

HTML <dialog> elements have their own internal state (.open property) and can be closed by the browser (e.g., pressing the Escape key) without React's direct knowledge. useSyncExternalStore ensures React's state always reflects the actual DOM state, preventing desynchronization.
import { useRef, useSyncExternalStore } from "react";
export default function App() {
const modalRef = useRef<HTMLDialogElement>(null);
// Define the subscribe function for the dialog's toggle event
const subscribe = (cb: () => void) => {
// Ensure modalRef.current exists before adding/removing listeners
if (!modalRef.current) return () => {}; // Return empty cleanup if ref is null
modalRef.current.addEventListener("toggle", cb); // Listen for the native 'toggle' event of the dialog
return () => {
modalRef.current?.removeEventListener("toggle", cb); // Cleanup listener
};
};
// Define the getSnapshot function to retrieve the current open state of the dialog
const getSnapshot = () => {
// Return the current 'open' property of the dialog, default to false if ref is null
return modalRef.current?.open ?? false;
};
// getServerSnapshot provides an initial state for server-side rendering
// On the server, the DOM element won't exist, so we default to closed.
const getServerSnapshot = () => false;
// Use useSyncExternalStore to get the synchronized open status of the dialog
const isOpen = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return (
<>
<button
onClick={() => {
modalRef.current?.showModal(); // Open the modal using its native method
// No need to manually setIsOpen(true) here, useSyncExternalStore handles state synchronization
}}
>
Open Modal
</button>
<p>{isOpen ? "Opened" : "Closed"}</p>
{/* The 'open' prop is now controlled by the external store's state */}
<dialog ref={modalRef} open={isOpen}>
<button
onClick={() => {
modalRef.current?.close(); // Close the modal using its native method
// No need to manually setIsOpen(false) here
}}
>
Close
</button>
</dialog>
</>
);
}
Step 3 — Creating a Custom Global Store (Todo App)

To create a global state management solution without React Context or external libraries like Redux, useSyncExternalStore allows components to subscribe to specific parts of the store and re-render only when those parts change. This avoids performance issues of useContext and complexity of useEffect.
First, create the external store (TodoStore.ts):
// TodoStore.ts
// The actual external state, a mutable array of strings
let todos: string[] = [];
// A Set to hold all registered listener callbacks
const listeners = new Set<() => void>();
// Function to get the current snapshot of the todos array
export const getTodos = (): string[] => {
return todos; // Returns the current state
};
// Function to add a todo to the store
export const addTodo = (name: string) => {
todos = [...todos, name]; // Create a new array reference (immutability for React detection)
listeners.forEach((listener) => listener()); // Notify all subscribed components of the change
};
// Function to remove a todo by index from the store
export const removeTodo = (index: number) => {
todos = todos.toSpliced(index, 1); // Create a new array reference (immutability)
listeners.forEach((listener) => listener()); // Notify all subscribed components
};
// Function to subscribe to store changes
export const subscribe = (cb: () => void) => {
listeners.add(cb); // Add the callback to the set of listeners
// Return a cleanup function to remove the listener when it's no longer needed
return () => {
listeners.delete(cb);
};
};
// Export the store object containing all functions needed by consumers
export const TodoStore = {
getTodos,
addTodo,
removeTodo,
subscribe,
};
Next, consume the store in your React component (App.tsx):
// App.tsx
import { useRef, useSyncExternalStore } from "react";
// Import the custom TodoStore
import { TodoStore } from "./TodoStore";
export default function App() {
const nameRef = useRef<HTMLInputElement>(null);
// Use useSyncExternalStore to get the todos from the global store
// TodoStore.subscribe is the function to register callbacks for changes.
// TodoStore.getTodos is the function to get the current state snapshot.
// TodoStore.getTodos is also used for getServerSnapshot if client-only or initial state is same.
const todos = useSyncExternalStore(
TodoStore.subscribe,
TodoStore.getTodos,
TodoStore.getTodos
);
// Handle form submission to add a new todo
function onSubmit(e: React.FormEvent) {
e.preventDefault(); // Prevent default form submission behavior
if (nameRef.current?.value) {
TodoStore.addTodo(nameRef.current.value); // Add todo via the global store's addTodo function
nameRef.current.value = ""; // Clear the input field after adding
}
}
return (
<>
<form onSubmit={onSubmit}>
<input ref={nameRef} />
</form>
<ul>
{todos.map((todo, index) => (
<li key={index} onClick={() => TodoStore.removeTodo(index)}> {/* Remove todo via the global store's removeTodo function */}
{todo}
</li>
))}
</ul>
</>
);
}
⚠️ Common Mistakes & Pitfalls
- Over-reliance on
useEffectfor external state: UsinguseEffectfor synchronizing with external, mutable stores often leads to complex boilerplate code for setup and cleanup, and can result in desynchronization if not handled perfectly.useSyncExternalStoreprovides a dedicated and simpler API for this specific use case. - Forgetting cleanup functions in
subscribe: Thesubscribefunction passed touseSyncExternalStoremust return a cleanup function. Failing to do so will lead to memory leaks as event listeners or subscriptions will persist even after the component unmounts. - Direct mutation of external state without notifying listeners: When creating a custom store, if you directly mutate the state (e.g.,
todos.push(item)) without creating a new reference and then don't call the registered listeners,useSyncExternalStorewon't detect a change, and consuming components won't re-render. Always create a new state reference (e.g.,todos = [...todos, item]) and notify listeners. - Inconsistent
getSnapshotbehavior: ThegetSnapshotfunction must return the exact same value if the store's data hasn't changed. If it returns a new object reference every time (even if the content is identical), it will cause unnecessary re-renders. Ensure deep equality checks or memoization if the snapshot is a complex object.
Glossary
useSyncExternalStore: A React Hook that allows components to subscribe to an external, mutable store, ensuring that React state is always synchronized with the external store's state.useEffect: A React Hook that lets you perform side effects in function components, often used for data fetching, subscriptions, or manually changing the DOM.HTMLDialogElement: A built-in HTML element that represents a dialog box or other interactive component, such as a dismissible alert, inspector, or subwindow.
Key Takeaways
useSyncExternalStoreis specifically designed for synchronizing React state with external, mutable data sources like browser APIs, global state managers, or caches.- It significantly simplifies code by abstracting away the complexities of managing subscriptions and cleanup that would typically require
useEffect. - The hook guarantees that React components always read the most up-to-date value from the external store, preventing state desynchronization between React and the external system.
- It requires a
subscribefunction (to register callbacks for changes), agetSnapshotfunction (to read the current state), and an optionalgetServerSnapshotfunction (for server-side rendering). - This hook enables the creation of lightweight, performant global state management solutions without the overhead of full-fledged libraries or the cascading re-rendering issues often associated with
useContext. - When building custom stores, it's crucial to create new state references (e.g., using spread syntax or
toSpliced) when updating the store to ensure React's change detection works correctly. - The
getServerSnapshotparameter is vital for server-side rendering, providing an initial state for hydration before client-side JavaScript takes over.
Resources
- React Hooks Simplified Course: https://courses.webdevsimplified.com/courses/react-hooks-simplified
- React
useSyncExternalStoreofficial documentation: https://react.dev/reference/react/useSyncExternalStore - HTML
<dialog>element documentation: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog