February 7, 2025

React view models and useSyncExternalStore

I’m currently building a browser-based multiplayer game using WebRTC, authoritative servers, and HTML5. The game lives in a standalone library, and is hosted on a Nextjs app.

Bridging the game code with the React app has been challenging. The UI game logic is pretty complex. There’s a traditional HTML5 renderer which iterates through a bunch of sprites every animation frame. There’s UDP-based P2P channels which send over animation and movement cues. There’s a movement system running on a per-tick loop which updates all players positions. All of these things have to be deterministic to prevent syncronization issues. Trying to express game logic like this the React way” didn’t feel right. Most of this state wasn’t rendered in the UI, so the hooks ended up holding on to references to different things (AudioManager, GraphicsController, PeerConnectionManager) and orchestrating them. This orchestration required me to compose a lot of hooks, manage a lot of effects, and ultimately couple my entire game engine to the React paradigms. More and more it required me to write my HTML5 rendering library, WebRTC library, network adapters, and so on, directly against the React framework, making them React libraries instead of simply Typescript libraries.

In the end, I successfully avoided this by using useSyncExternalStore, a React hook designed to… sync external stores.

Here’s a simple example with blog posts. Imagine Posts data lives in some class that will be annoying to put inside of React.

class PostManager {
    posts: Post[] = []

    addPost = (post: Post) => {
      // How to update posts in React? This won't trigger a rerender.
      this.posts = [...this.posts, post]
    }
}

Instead of glueing a bunch of hooks and effects together, we can simply add an observable to our posts class.

class PostManager {
    // List of subscribed observers
    postListeners = []

    // Subscribe function for observers
    subscribe = (listener) => {
      this.postListeners = [...this.postListeners, listener]
      // Return an unsubscribe function
      return () => {
        this.postListeners = this.postListeners.filter((l) => l !== listener)
      }
    }

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

Then, we can utilize useSyncExternalStore to access our posts as an observer.

const postsManager = new PostManager()

const PostList = () => {
  const posts = useSyncExternalStore(
    // useSyncExternalStore takes a subscribe function 
    postsManager.subscribe, 
    // A getter for the initial data, before `subscribe` is called
    () => postsManager.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 rendering model, useSyncExternalStore expands the horizon of what can be accomplished without third party state management libraries.

So I did just that - I took this posts concept and created a generic view model. While I think hooks themselves are cleaner for idiomatic React, there have been plenty of times where I actually prefer the view model API.

// view-model.ts

import { useRef, useSyncExternalStore } from "react";

// generic listener type, called whenever state updates
export type Listener<T> = (t: T) => void;

abstract class ViewModel<State> {
  // Keeping the state field private prevents mutations without calling setState
  private state: State;

  constructor(defaultState: State) {
    this.state = defaultState;
  }

  private listeners: Array<Listener<State>> = [];

  public getState = () => {
    return this.state;
  };

  // Whenever updating state, call setState instead of directly mutating state
  protected setState(x: State) {
    this.state = structuredClone(x);
    this.listeners.forEach((fn) => fn(x));

  }

  public subscribe = (listener: Listener<State>) => {
    this.listeners = [...this.listeners, listener];
    return () => {
      this.listeners = this.listeners.filter((x) => x !== listener);
    };
  };
}

And a generic hook to support the view model:

// use-view-model.ts

// Our store implementation. Stores the VM as a ref and subscribes to it via useSyncExternalStore
// This requires the caller to store the VMs in state if multiple components are utilizing the same VM
export function useViewModel<T extends ViewModel<any>>(
  vm: T
): [ReturnType<T["getState"]>, T] {
  const vmRef = useRef(vm);
  return [
    useSyncExternalStore(vmRef.current.subscribe, vmRef.current.getState),
    vmRef.current,
  ];
}

Example use

class RandomNumberService extends ViewModel<number> {
  public randomNumberApi = async () => {
      const response = await fetch("https://random.com/number")
      return response.data
  }
 
  public getNumber = async () => {
    // Handle loading state etc
    const number = await randomNumberApi()
    this.setState(number);
  };
}

export const NumberComponent = () => {
  const [number, vm] = useViewModel(new RandomNumberService(1));
  return (
    <button onClick={vm.getNumber}>
      {number}
    </button>
  );
};

You can see just how easy this is. You can inject an entire view model into a useState-style hook with almost no boilerplate. Instead of a setter like with useState you have access to the entire VM interface as a state machine. useState indeed.

Final thoughts

I ripped this pattern off of what I found to be the natural approach when learning SwiftUI. Because of Swift’s fine-grained control with @state and excellent getters and setters, I found storing my business logic in classes to feel very natural.

But do I recommend using classes in React? Not really. I’m sure this is an abomination to many. But sometimes it can be useful. Not all code is best described declaratively.



Copyright Nathanael Bennett 2025 - All rights reserved