A Look At React Hooks: useSyncExternalStore

A Look At React Hooks: useSyncExternalStore

An awesome library Hook to integrate non-React state management in your apps!

Welcome to another article of A Look at React Hooks, a beginner-friendly series on React Hooks. In this article, let's learn about the useSyncExternalStore Hook. It is a useful Hook for when you need to integrate non-React state management in your apps.

Before we begin, you should have a basic knowledge of React and how React Hooks work. If not, please read the React Hook series from the beginning.

What is useSyncExternalStore

useSyncExternalStore a custom hook available in React 18, that lets you subscribe to an external store, reads updated values from that store and updates components if needed.

By 'external store', it means:

  • Third-party state management libraries that hold state outside of React

  • Browser APIs that expose a mutable value and events to subscribe to its changes

Some examples of 'external store' includes:

  • Browser history

  • localStorage

  • Third-party data sources

Why use useSyncExternalStore

According to the official docs, this Hook is useful when you want to update React components when some data in external stores changes. Without useSyncExternalStore Hook, you would need to manually subscribe to these external stores using useEffect.

In some cases, this might cause over-returning Hooks and unwanted re-rendering of components. Hence, it is more optimal to use useSyncExternalStore. Note that the Hook itself doesn't inherently mitigate or address over-re-rendering issues caused by the use of useEffect. Instead, it provides a way to synchronize your component with an external store in a more controlled manner, which may make your code less error-prone.

Here's an example: say you have a chat app like Discord with multiple text channels. You fetch the messagesData from an external database like firestore.

You want the app to do 2 things:

  1. When a new message is added to messagesData by the user, jump to the most recent message to display the newly added message in the window

  2. When a new message is added to messagesData by another user to another text channel, display the newly added message when the user switches text channel

So, you might use useEffect to achieve these 2 things like this:

  const [messagesData, setMessagesData] = useState([]);
  const [currentChannel, setCurrentChannel] = useState('general');
  const messagesEndRef = useRef(null);

  // useEffect to handle jumping to the most recent message when a new message is added
  useEffect(() => {
    // Assuming you have a function to fetch messages from Firestore
    const fetchMessages = async () => {
      const messages = await fetchMessagesFromFirestore(currentChannel);
      setMessagesData(messages);
      scrollToBottom();
    };

    fetchMessages();

    // Subscribe to changes in Firestore for the current channel
    const unsubscribe = subscribeToChannel(currentChannel, (newMessages) => {
      setMessagesData(newMessages);
      scrollToBottom();
    });

    return () => {
      // Clean up the subscription when the component unmounts or the channel changes
      unsubscribe();
    };
  }, [currentChannel]); // Re-run the effect when the current channel changes

  // useEffect to handle displaying a new message when switching channels
  useEffect(() => {
    const unsubscribe = subscribeToChannel(currentChannel, (newMessages) => {
      setMessagesData(newMessages);
      scrollToBottom();
    });

    return () => {
      unsubscribe();
    };
  }, [currentChannel]);

  const scrollToBottom = () => {
    // Scroll to the bottom of the messages container
    if (messagesEndRef.current) {
      messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
    }
  };

  const handleChannelChange = (newChannel) => {
    setCurrentChannel(newChannel);
  };

As seen from the example above, the useEffect for fetching messages is triggered whenever the currentChannel changes. While this is necessary for updating the messages for the new channel, it might lead to unnecessary re-renders if the component is re-rendered for reasons unrelated to the channel change.

Implementation

Here's a simple example of how to use the Hook:

const externalStore = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

The Hook returns a snapshot of the external store's data you are subscribed to.

  • subscribe : Invokes callback function that triggers when component re-renders. It should also handle the cleanup of the subscription.

  • getSnapshot : This fetches and returns a snapshot of the data subscribed. If the returned value is different, then the component re-renders.

  • getServerSnapshot (optional): This function returns the initial snapshot of the data in the store and provides the snapshot during server-side rendering.

Minimalistic Example

Let's see how this Hook works in an example. Let's say you want to listen for changes in localStorage.

First, you would import the Hook in your component.

import {useSyncExternalStore} from 'react'

Then, you would implement the Hook to read data from localStorage like this:

//this is the subscribe function, listens for changes
const subscribe = (listener) => {
    window.addEventListener("storage", listener);
    return () => {
      window.removeEventListener("storage", listener);
    };
}
//this is the getSnapShot function, returns data subscribed
const getSnapShot = () => {
    return localStorage.getItem("example");
}
//implement the Hook
const exampleValue = useSyncExternalStore(subscribe, getSnapShot);

Keep in mind this is a minimalistic example, but it should give you a gist on how it works. So the subscribe function will listen for changes in localStorage and handle unsubscribing as well. Then getSnapShot gets the value of the data.

Finally, we use the useSyncExternalStore to ensure the component re-renders whenever a change is detected from localStorage, the external data store we are subscribing to.

For more examples, do visit the official website on useSyncExternalStore.

Another Example

How about we go back to the chat app example mentioned earlier? How can we use useSyncExternalStore to simplify synchronization with our external database?

As shown in the example below, using this Hook allows a centralized approach to handle data from external sources and a more simplified code for real-time data updates.

  const [currentChannel, setCurrentChannel] = useState('general');
  const messagesEndRef = useRef(null);

  // useSyncExternalStore for fetching messages
  const messages = useSyncExternalStore(
    () => subscribeToChannel(currentChannel, updateMessages),
    () => fetchMessagesFromFirestore(currentChannel),
    null // No server snapshot in this example, but you can add it if needed
  );

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  const updateMessages = (newMessages) => {
    // Handle the newly received messages
    // This callback will be invoked whenever there's a change in the external store
  };

  const scrollToBottom = () => {
    if (messagesEndRef.current) {
      messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
    }
  };

  const handleChannelChange = (newChannel) => {
    setCurrentChannel(newChannel);
  };

Conclusion

In conclusion, the useSyncExternalStore Hook has been a great and useful addition to React's kit. It would take time to learn some of the caveats of this Hook and fully understand how to implement it, so I hope this article is a good starting point for you.

Thanks for reading! Please share and like the article if it has been a helpful read. Also, do share in the comments your thoughts on this React Hook. Feel free to check out the References section below to read more. Till next time, cheers!

References

Let's Connect!

Did you find this article valuable?

Support Victoria Lo by becoming a sponsor. Any amount is appreciated!

ย