W
Web Dev Simplified
#React#React Hooks#useSyncExternalStore

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.

5 min readAI Guide

Introduction

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

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)

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

  1. Over-reliance on useEffect for external state: Using useEffect for synchronizing with external, mutable stores often leads to complex boilerplate code for setup and cleanup, and can result in desynchronization if not handled perfectly. useSyncExternalStore provides a dedicated and simpler API for this specific use case.
  2. Forgetting cleanup functions in subscribe: The subscribe function passed to useSyncExternalStore must 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.
  3. 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, useSyncExternalStore won'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.
  4. Inconsistent getSnapshot behavior: The getSnapshot function 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

  • useSyncExternalStore is 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 subscribe function (to register callbacks for changes), a getSnapshot function (to read the current state), and an optional getServerSnapshot function (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 getServerSnapshot parameter is vital for server-side rendering, providing an initial state for hydration before client-side JavaScript takes over.

Resources