React - My Pub/Sub Hook

Published: Oct 25, 2024
react
React three fiber
three.js
TypeScript

React provides tools like useState, useContext, and useReducer to share information between components, as well as state managers such as Redux and Zustand. React is well-equipped to track and share information in an optimised way.

So why consider an alternative to React state?

In my current projects, I often need to communicate rapidly changing information, such as the positions of objects being dragged around in 3D space, and to share these updates with other components in real-time. I need a solution suited to an event-driven system that doesn’t tightly couple components together, supports rapidly changing data, and performs actions according to those events in a highly efficient manner. The pub/sub pattern turned out to be an ideal fit for this.

What is pub/sub by the way?

Its a pattern in programming where components communicate indirectly by broadcasting (publishing) messages, and other components can listen (subscribe) to those messages:

  • Publisher: The entity that sends (publishes) data or events.
  • Subscriber: The entity that listens for specific data or events.
  • Mediator: The system that handles the subscription and publishing process.

This pub/sub approach steps away from React's way of working, where I am now responsible for things like subscription management, cleanup and optimisation.

Let me show you my solution...

In another project I'm working on, I've created a scene inspector for react three fiber, allowing me to inspect my 3D scene and all of its elements, including things like an elements position, material, rotation and various other attributes, in real time.

Babylon JS has a great scene inspector, and inspired me to build one since Three JS doesn't seem to have one. Maybe I'll create an NPM package and release it one day.

First, I initialise my pub/sub by creating a hook called useAppScene:

import { Object3D, Camera } from 'three'
export type PubSubTypes<T> = {
    subscribe: (callback: (data: T) => void) => () => void
    publish: (data: T) => void
}

type AppSceneType = {
    appSceneItems: PubSubTypes<Object3D[]>
    appSceneCameras: PubSubTypes<Camera>
}

const childrenSubscriberSet = new Set<(data: Object3D[]) => void>()
const cameraSubscriberSet = new Set<(data: Camera) => void>()

export const useAppScene = (): AppSceneType => {
    return {
        appSceneItems: createPubSub(childrenSubscriberSet),
        appSceneCameras: createPubSub(cameraSubscriberSet),
    }
}

PubSubTypes<T>

This type definition outlines the structure of the publish-subscribe system. <T>: is a generic type parameter which ensures that the same data type flows through subscribe and publish methods. It defines two methods:

  • subscribe: Takes a callback function that receives data of type T and returns an unsubscribe function.
  • publish: Takes data of type T and calls all subscribed callbacks with that data.

This type ensures that any pub-sub instance maintains consistent data types across its methods.

AppSceneType

This type defines the structure for the object returned by useAppScene, which holds pub-sub instances for two specific types:

  • appSceneItems is PubSubTypes<Object3D[]>, a pub-sub system for an array of Object3D objects.
  • appSceneCameras is PubSubTypes<Camera>, a pub-sub system for a Camera object.

This ensures that when calling publish or subscribe on appSceneItems or appSceneCameras, TypeScript will enforce that we’re working with Object3D[] and Camera

childrenSubscriberSet and cameraSubscriberSet

A Set only allows unique values, meaning that each callback function can only be added once. If the same function is subscribed multiple times, Set will ignore duplicates, ensuring efficient use of memory and improving performance. In comparison, an array could contain duplicates, making it harder to manage unique subscriptions without additional checks. This reduces boilerplate code and potential bugs, especially when managing many subscriptions across components.

useAppScene

This is the hook I export and use throughout my app allowing components to publish and subscribe to events. It creates two pub-sub systems for sharing the state of:

  • Scene items: The 3D objects (Object3D[]) in the scene.
  • Camera: The active camera (Camera) in the scene.

So far, this only really creates my hook, so where's the actual pub sub...?

Introducing the createPubSub() factory

The createPubSub function is the core of the pub-sub system. It is a reusable, type-safe, and efficient way to publish and subscribe to data within the application:

import { PubSubTypes } from './useAppScene'

export const createPubSub = &lt;T&gt;(
    subscriberSet: Set&lt;(data: T) =&gt; void&gt;
): PubSubTypes&lt;T&gt; =&gt; {
    return {
        subscribe: (callback: (data: T) =&gt; void) =&gt; {
            subscriberSet.add(callback)
            return () =&gt; subscriberSet.delete(callback)
        },
        publish: (data: T) =&gt; {
            subscriberSet.forEach(callback =&gt; callback(data))
        },
    }
}

This function takes the Set described earlier and a generic type parameter <T>, allowing createPubSub to work with any type (like Object3D[] or Camera) without restricting the data type beforehand.

The function returns an object with two methods:

  • subscribe(callback): Adds a callback to subscriberSet. It also returns an "automatic cleanup function", so when a subscriber unsubscribes (e.g., when a component unmounts), Set.delete makes it easy to remove the callback, preventing potential memory leaks.
  • publish(data): Iterates over subscriberSet and calls each callback with data as the argument, broadcasting the data to all subscribers in a consistent order.

My favourite part about this pub/sub is the use of the Set, where calling subscriberSet.add and subscriberSet.delete is much cleaner, direct and efficient than doing all that with arrays. This keeps the code concise and reduces the need for manual checks.

The icing on the cake is the callback that is returned to clean everything up which you will see in the later components.

Publishing scene items

My CanvasReporter component’s role is to collect and publish the current state of the 3D scene and camera, making it accessible to other parts of the application via the pub-sub system:

import { FC } from 'react'
import { useFrame, useThree } from '@react-three/fiber'

export const CanvasReporter: FC = () =&gt; {
    const { scene, camera } = useThree()
    const { appSceneItems, appSceneCameras } = useAppScene()

    useFrame(() =&gt; {
        appSceneItems.publish(scene.children)
        appSceneCameras.publish(camera)
    })

    return null
}

The useThree() hook provides access to the current scene and camera, along with their information from the canvas. The useFrame hook runs on every animation frame (typically 60 frames per second) and uses my useAppScene hook:

  • Publishes scene.children via appSceneItems.publish(scene.children), making the current set of objects in the scene accessible to any subscribers.
  • Publishes camera via appSceneCameras.publish(camera), allowing other components to access the camera’s latest state.

Since useFrame runs on each frame, CanvasReporter continuously sends the latest scene.children and camera data to all subscribers, keeping them updated in real-time. This is especially useful in 3D applications, where object positions, orientations, or properties may change frequently.

By using the pub/sub pattern, CanvasReporter doesn’t need to know which components are listening to these updates and is decoupled from them. Any component that subscribes to appSceneItems or appSceneCameras will automatically receive the latest scene and camera data.

Now information has been published, it's time to subscribe...

Subscribing to scene changes

My SceneItems component subscribes to the current state of scene.children in the 3D scene, then renders a list of these scene objects. It uses the pub-sub system to receive updates about changes in the scene, keeping the displayed list synchronised with the objects present in the 3D canvas.

Following is a simplified version of my scene inspector, as I have removed much of the other functionality and logic for the purpose of this article so it's easier to see the pub/sub in action.
import { FC, useState, useEffect } from 'react'
import { useAppScene } from './useAppScene'

export const SceneItems: FC = () =&gt; {
    const { appSceneItems } = useAppScene()
    const [sceneChildren, setSceneChildren] = useState&lt;Object3D[]&gt;([])
    const [selectedItem, setSelectedItem] = useState&lt;Object3D | undefined&gt;(undefined)

    useEffect(() =&gt; {
        const unsubscribeSceneItems = appSceneItems.subscribe(children =&gt; {
            setSceneChildren(children)
        })
        return () =&gt; {
            unsubscribeSceneItems()
        }
    }, [appSceneItems])

    return (
        &lt;ul&gt;
            {sceneChildren.map(node =&gt; (
                &lt;li key={node.data.uuid}&gt;
                    &lt;button
                        type=&quot;button&quot;
                        onClick={() =&gt; setSelectedItem(node)}
                    &gt;
                        {node.data.name || node.data.type}
                    &lt;/button&gt;
                &lt;/li&gt;
            ))}
        &lt;/ul&gt;
    )
}

The component retrieves appSceneItems from useAppScene(), which is a pub-sub instance created specifically for managing subscriptions to the scene.children data.

useEffect is used to set up a subscription to appSceneItems:

  • When appSceneItems publishes new data, the callback inside subscribe is invoked, receiving the latest scene.children array.
  • The callback updates sceneChildren state with setSceneChildren, ensuring that sceneChildren always reflects the current state of the objects in the scene.
  • The unsubscribeSceneItems function returned by subscribe is used in the cleanup of useEffect, ensuring that the subscription is removed if the component unmounts, preventing memory leaks.

Rendering the List of Scene Objects:

  • The component maps over sceneChildren to render a list of items, where each item corresponds to an object in the scene.
  • For each object (node), a button displays the object’s name or type, and clicking it sets some state for the node. I use this state elsewhere in my scene inspector to display all the information about that 3D object.

In conclusion:

SceneItems only displays the data it needs, while CanvasReporter continuously updates appSceneItems on each frame. This ensures that if objects are added or removed from the scene, SceneItems will reflect these changes automatically.

The subscription model is memory efficient and safe, as unsubscribeSceneItems stops updates to SceneItems when it unmounts, preventing any potential memory leaks or redundant updates. Since I am now responsible for the cleanup process, I'm really happy with how I have solved this challenge so it automatically handles this task.

This component remains decoupled from the source of the updates (CanvasReporter), improving modularity and code maintenance in applications with complex, dynamic data. It will be easy for me to integrate this system into many other projects I can think of.

Using pub/sub keeps components isolated, meaning they can interact without being wired together. Compared to prop drilling or lifting state to a common ancestor, pub/sub simplifies code structure and makes it easy to add or remove interactions without refactoring everything if new requirements arise or if I change my mind about the architecture.

React state is ideal for straightforward, UI-driven data management, whereas in this instance, pub/sub is better suited for decoupled, event-driven communication, especially in real-time applications like 3D rendering, where performance and flexibility are really important to me and the type of applications I'm building at the moment.