avatarVitalii Shevchuk

Summary

The provided content discusses best practices for using the useSyncExternalStore hook in React 18 to synchronize components with external data sources, prevent visual inconsistencies caused by tearing, and ensure proper state management with concurrent rendering.

Abstract

The useSyncExternalStore hook is introduced in React 18 as a solution to the issue of "tearing," where UI components may display multiple states simultaneously due to concurrent rendering. This hook allows developers to subscribe to external stores such as Redux, Zustand, or browser APIs like window.matchMedia, ensuring that components remain in sync with the external data they depend on. The article outlines the basic usage of the hook, provides examples of integrating it with different types of stores, and emphasizes best practices such as maintaining immutable snapshots, declaring subscribe functions outside components, handling multiple stores, and supporting server-side rendering. By following these guidelines, developers can enhance the performance and reliability of their React applications when working with external state.

Opinions

  • The author suggests that the snapshot returned by the getSnapshot function should be immutable to prevent unnecessary re-renders and ensure consistency.
  • It is recommended to declare the subscribe function outside the component to avoid unnecessary re-subscriptions during re-renders.
  • The article conveys that useSyncExternalStore can be used multiple times within a component to subscribe to different external stores, providing flexibility in managing diverse data sources.
  • The author emphasizes the importance of consistency between client and server snapshots when implementing server-side rendering, suggesting the use of serialization techniques.
  • The article encourages the extraction of common logic for subscribing to external stores into custom hooks, promoting code reusability and maintainability.
  • The author provides examples of using useSyncExternalStore with popular state management libraries like Redux and Zustand, showcasing its versatility and ease of integration.
  • The conclusion of the article implies that adhering to the outlined best practices is crucial for effectively utilizing useSyncExternalStore and building better React applications.

⚛️ Best Practices of useSyncExternalStore in React

useSyncExternalStore is a React Hook that lets you subscribe to an external store. An external store is something that you can subscribe to, such as Redux store, Zustand store, global variables, module scope variables, DOM state, etc. It is different from internal stores, such as props, context, useState, useReducer, which are managed by React.

useSyncExternalStore is introduced in React 18 to solve the problem of tearing, which refers to visual inconsistency when a UI shows multiple values for the same state. This can happen with concurrent rendering, which is a new feature of React 18 that allows React to render multiple tasks simultaneously and prioritize them based on their importance.

Content

How to Use useSyncExternalStore

The basic usage of useSyncExternalStore is as follows:

import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';

function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
  // ...
}

It returns the snapshot of the data in the store that you can use in your rendering logic. You need to pass two functions as arguments:

  • The subscribe function should subscribe to the store and return a function that unsubscribes. When the store changes, it should invoke the provided callback. This will cause the component to re-render.
  • The getSnapshot function should read a snapshot of the data from the store that’s needed by the component. While the store has not changed, repeated calls to getSnapshot must return the same value. If the store changes and the returned value is different (as compared by Object.is), React re-renders the component.

You can also pass an optional third argument, getServerSnapshot, which is a function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server, and is usually serialized and passed from the server to the client.

Examples of Using useSyncExternalStore

Here are some examples of using useSyncExternalStore in React 18:

  • Subscribing to a Redux store
import { useSelector } from 'react-redux';
import { useSyncExternalStore } from 'react';

// A custom Hook that wraps useSyncExternalStore
function useRedux(selector) {
  const store = useSelector();
  const subscribe = useCallback((callback) => {
    const unsubscribe = store.subscribe(callback);
    return unsubscribe;
  }, [store]);
  const getSnapshot = useCallback(() => selector(store.getState()), [
    selector,
    store,
  ]);
  return useSyncExternalStore(subscribe, getSnapshot);
}
// A component that uses useRedux
function Counter() {
  const count = useRedux((state) => state.count);
  return <div>{count}</div>;
}
  • Subscribing to a Zustand store
import create from 'zustand';
import { useSyncExternalStore } from 'react';

// A Zustand store
const todosStore = create((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
}));
// A custom Hook that wraps useSyncExternalStore
function useZustand(selector) {
  const subscribe = useCallback((callback) => {
    const unsubscribe = todosStore.subscribe(callback);
    return unsubscribe;
  }, []);
  const getSnapshot = useCallback(() => selector(todosStore.getState()), [
    selector,
  ]);
  return useSyncExternalStore(subscribe, getSnapshot);
}
// A component that uses useZustand
function TodosApp() {
  const todos = useZustand((state) => state.todos);
  const addTodo = useZustand((state) => state.addTodo);
  // ...
}
  • Subscribing to window.matchMedia
import { useState } from 'react';
import { useSyncExternalStore } from 'react';

// An external store for window.matchMedia
const mediaQueryStore = {
  subscribe(callback) {
    const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
    mediaQueryList.addEventListener('change', callback);
    return () => mediaQueryList.removeEventListener('change', callback);
  },
  getSnapshot() {
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  },
};
// A component that uses mediaQueryStore
function ThemeSwitcher() {
  const prefersDarkMode = useSyncExternalStore(
    mediaQueryStore.subscribe,
    mediaQueryStore.getSnapshot
  );
  const [theme, setTheme] = useState(prefersDarkMode ? 'dark' : 'light');
  
  // ...
}

Best Practices of Using useSyncExternalStore

Here are some Best Practices of using useSyncExternalStore :

  • The store snapshot returned by getSnapshot must be immutable. If the underlying store has mutable data, return a new immutable snapshot if the data has changed. Otherwise, return a cached last snapshot.
// Assume store is an object that has a data property that can be mutated
// Assume Immutable is a library that can create immutable copies of objects
let lastSnapshot = null; // A cached snapshot

function getSnapshot(store) {
  // Check if the store data has changed since the last snapshot
  if (lastSnapshot && lastSnapshot.data === store.data) {
    // Return the cached snapshot
    return lastSnapshot;
  } else {
    // Create a new immutable snapshot
    let newSnapshot = Immutable.fromJS(store);
    // Update the cached snapshot
    lastSnapshot = newSnapshot;
    // Return the new snapshot
    return newSnapshot;
  }
}
  • If a different subscribe function is passed during a re-render, React will re-subscribe to the store using the newly passed subscribe function. You can prevent this by declaring subscribe outside the component.
// Assume store is an object that has a subscribe method that takes a callback function
// Assume useSyncExternalStore is a custom hook that uses the subscribe and getSnapshot functions
// Assume getSnapshot is another function that returns a snapshot of the store data

// Declare the subscribe function outside the component
function subscribe(callback) {
  // Subscribe to the store and return an unsubscribe function
  return store.subscribe(callback);
}

// Define the component that uses the subscribe function
function MyComponent(props) {
  // Use the useSyncExternalStore hook to get the store data
  const data = useSyncExternalStore(subscribe, getSnapshot);
  // Render the data
  return <div>{data}</div>;
}

// Pass the subscribe function as a prop to the component
<MyComponent subscribe={subscribe} />;
// Assume store1 and store2 are objects that have subscribe and getSnapshot methods
// Assume useSyncExternalStore is a custom hook that uses the subscribe and getSnapshot functions

// Define the component that uses useSyncExternalStore multiple times
function MyComponent(props) {
  // Use useSyncExternalStore to get the data from store1
  const data1 = useSyncExternalStore(store1.subscribe, store1.getSnapshot);
  // Use useSyncExternalStore to get the data from store2
  const data2 = useSyncExternalStore(store2.subscribe, store2.getSnapshot);
  // Render the data
  return (
    <div>
      <p>Data from store1: {data1}</p>
      <p>Data from store2: {data2}</p>
    </div>
  );
}
  • If you need to subscribe to a browser API, such as window.location or window.matchMedia, you can wrap it in an external store and use useSyncExternalStore to access it.
// Assume useSyncExternalStore is a custom hook that uses the subscribe and getSnapshot functions

// Define a function that subscribes to the window.location API
function subscribeLocation(callback) {
  // Add an event listener for the popstate event
  window.addEventListener("popstate", callback);
  // Return a function that removes the event listener
  return () => {
    window.removeEventListener("popstate", callback);
  };
}

// Define a function that gets the current location
function getLocation() {
  // Return the window.location object
  return window.location;
}

// Define a component that uses useSyncExternalStore to access the location
function LocationDisplay(props) {
  // Use useSyncExternalStore to get the location
  const location = useSyncExternalStore(subscribeLocation, getLocation);
  // Render the location
  return <div>{location.href}</div>;
}
// Assume store is an object that has subscribe and getSnapshot methods
// Assume useSyncExternalStore is a custom hook that uses the subscribe and getSnapshot functions

// Define a custom Hook that uses useSyncExternalStore
function useStoreData(selector) {
  // Use useSyncExternalStore to get the snapshot of the store data
  const data = useSyncExternalStore(store.subscribe, store.getSnapshot);
  // Apply the selector function to the data
  const selectedData = selector(data);
  // Return the selected data
  return selectedData;
}

// Define a component that uses the custom Hook
function MyComponent(props) {
  // Use the custom Hook to get the data from the store
  const data = useStoreData((data) => data.someProperty);
  // Render the data
  return <div>{data}</div>;
}
  • If you need to add support for server rendering, you can pass getServerSnapshot as the third argument to useSyncExternalStore and make sure it returns the same value as getSnapshot on both client and server.
// Assume store is an object that has subscribe, getSnapshot and getServerSnapshot methods
// Assume useSyncExternalStore is a custom hook that uses the subscribe and getSnapshot functions

// Define a component that uses useSyncExternalStore with getServerSnapshot
function MyComponent(props) {
  // Use useSyncExternalStore to get the snapshot of the store data
  // Pass getServerSnapshot as the third argument
  const data = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot
  );
  // Render the data
  return <div>{data}</div>;
}

// Make sure that getServerSnapshot returns the same value as getSnapshot on both client and server
// For example, if the store data is serialized and passed from the server to the client
// You can use JSON.parse and JSON.stringify to ensure consistency
store.getSnapshot = () => {
  // Return the store data as an object
  return store.data;
};

store.getServerSnapshot = () => {
  // Return the store data as a string
  return JSON.stringify(store.data);
};

// On the client side, parse the server snapshot and assign it to the store data
store.data = JSON.parse(store.getServerSnapshot());

Examples of using useSyncExternalStore

Here are some examples of using useSyncExternalStore in React 18:

Subscribing to a Redux store

import { useSelector } from 'react-redux';
import { useSyncExternalStore } from 'react';

// A custom Hook that wraps useSyncExternalStore
function useRedux(selector) {
  const store = useSelector();
  const subscribe = useCallback((callback) => {
    const unsubscribe = store.subscribe(callback);
    return unsubscribe;
  }, [store]);
  const getSnapshot = useCallback(() => selector(store.getState()), [
    selector,
    store,
  ]);
  return useSyncExternalStore(subscribe, getSnapshot);
}
// A component that uses useRedux
function Counter() {
  const count = useRedux((state) => state.count);
  return <div>{count}</div>;
}

Subscribing to a Zustand store

import create from 'zustand';
import { useSyncExternalStore } from 'react';

// A Zustand store
const todosStore = create((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
}));
// A custom Hook that wraps useSyncExternalStore
function useZustand(selector) {
  const subscribe = useCallback((callback) => {
    const unsubscribe = todosStore.subscribe(callback);
    return unsubscribe;
  }, []);
  const getSnapshot = useCallback(() => selector(todosStore.getState()), [
    selector,
  ]);
  return useSyncExternalStore(subscribe, getSnapshot);
}
// A component that uses useZustand
function TodosApp() {
  const todos = useZustand((state) => state.todos);
  const addTodo = useZustand((state) => state.addTodo);
  // ...
}

Subscribing to window.matchMedia

import { useState } from 'react';
import { useSyncExternalStore } from 'react';

// An external store for window.matchMedia
const mediaQueryStore = {
  subscribe(callback) {
    const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
    mediaQueryList.addEventListener('change', callback);
    return () => mediaQueryList.removeEventListener('change', callback);
  },
  getSnapshot() {
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  },
};
// A component that uses mediaQueryStore
function ThemeSwitcher() {
  const prefersDarkMode = useSyncExternalStore(
    mediaQueryStore.subscribe,
    mediaQueryStore.getSnapshot
  );
  const [theme, setTheme] = useState(prefersDarkMode ? 'dark' : 'light');
  
  // ...
}

Conclusion

useSyncExternalStore is a new hook in React 18 that lets you subscribe to an external store and update your component when the store changes. It solves the problem of tearing that can occur with concurrent rendering. It also provides a consistent way of accessing external data sources that are not based on React state management.

To use it effectively, you need to follow some best practices, such as making sure your snapshots are immutable, declaring your subscribe functions outside your components, calling it multiple times for multiple stores, wrapping browser APIs in external stores, extracting custom Hooks for common logic, and adding support for server rendering. If you enjoy reading, don’t forget to 👏 and subscribe 📬 and leave the comment 🗣️

Learn More

JavaScript
Programming
Software Development
Web Development
Technology
Recommended from ReadMedium