January 3, 2023

useSyncExternalStore is the hook you didn’t know you need

I often see engineers from outside the React community start their React apps by creating architectures similar to what you see in OOP languages. They want IOC containers, class-based view models, and dependency injection.

Getting enterprise architects to learn the React way” (that is: hooks, FP, composition) is too much to ask. But architecting React apps the OOP way” usually ends with a lot of patterns that hinder the project’s DX.

For example: we have a PostManager class holding the state of some Posts. It belongs to a service module which sits outside of React. Our big ol’ enterprise created it to make reusable UI services”. The idea that the enterprise architect had is that you could reuse this module, and not have to manage the state or services across the enterprise API layer.

class PostManager {
    posts: Post[] = []

    addPost = (post: Post) => {
      this.posts = [...this.posts, post]
    }
}

If you’re a veteran React dev you can already see the problem. We’re holding onto state in a class that sits outside of the React VDOM.

So how do we go about displaying these Posts within a React component? It’s actually quite difficult.

There is no guarantee React will rerender whenever we update our Posts. In order to get a component to update upon adding a Post, we need some sort of trigger to rerender. From the class-component-based this.forceUpdate, to creating dummy state to trigger rerenders, most of the solutions are far less than ideal.

DoorDash ran into this exact problem, and they went with the nuclear option” of cloning the entire class into a new state inside of a reducer after every update.

To deal with this problem, React 18 comes with useSyncExternalStore, an observer-style hook to bridge non-React modules. Back to our PostManager:

class PostManager {
    posts: Post[] = []

    addPost = (post: Post) => {
      this.posts = [...this.posts, post]
      // Broadcast the Posts update to each listener, observer-style
      this.postListeners.forEach(listener => listener(this.posts))
    }

    postListeners = []

    // Subscribe to Posts, return the unsubscribe function
    subscribe = (listener) => {
      this.postListeners = [...this.postListeners, listener]
      return () => {
        this.postListeners = this.postListeners.filter((l) => l !== listener)
      }
    }
}

const concretePostManager = new PostManager(..)

const PostList = () => {
  // useSyncExternalStore takes a subscribe function 
  const posts = useSyncExternalStore(
    concretePostManager.subscribe, 
    () => concretePostManager.posts
  )
  return posts.map(...)
}

In the trivial amount of time it takes to implement an observable, we’ve made an external class totally congruous with React.

Beyond making legacy modules more compatible with React’s declarative model, my hope is useSyncExternalStore expands the horizon of what can be accomplished without third party state management libraries.

And as with anything, there are a fair share of caveats and gotchas worth understanding.

Happy hunting!



Copyright Nathanael Bennett 2025 - All rights reserved