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 typeT
and returns an unsubscribe function.publish
: Takes data of typeT
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
isPubSubTypes<Object3D[]>
, a pub-sub system for an array ofObject3D
objects.
appSceneCameras
isPubSubTypes<Camera>
, a pub-sub system for aCamera
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 = <T>(
subscriberSet: Set<(data: T) => void>
): PubSubTypes<T> => {
return {
subscribe: (callback: (data: T) => void) => {
subscriberSet.add(callback)
return () => subscriberSet.delete(callback)
},
publish: (data: T) => {
subscriberSet.forEach(callback => 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 tosubscriberSet
. 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 oversubscriberSet
and calls each callback withdata
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 = () => {
const { scene, camera } = useThree()
const { appSceneItems, appSceneCameras } = useAppScene()
useFrame(() => {
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
viaappSceneItems.publish(scene.children)
, making the current set of objects in the scene accessible to any subscribers. - Publishes
camera
viaappSceneCameras.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 = () => {
const { appSceneItems } = useAppScene()
const [sceneChildren, setSceneChildren] = useState<Object3D[]>([])
const [selectedItem, setSelectedItem] = useState<Object3D | undefined>(undefined)
useEffect(() => {
const unsubscribeSceneItems = appSceneItems.subscribe(children => {
setSceneChildren(children)
})
return () => {
unsubscribeSceneItems()
}
}, [appSceneItems])
return (
<ul>
{sceneChildren.map(node => (
<li key={node.data.uuid}>
<button
type="button"
onClick={() => setSelectedItem(node)}
>
{node.data.name || node.data.type}
</button>
</li>
))}
</ul>
)
}
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 insidesubscribe
is invoked, receiving the latestscene.children
array. - The callback updates
sceneChildren
state withsetSceneChildren
, ensuring thatsceneChildren
always reflects the current state of the objects in the scene. - The
unsubscribeSceneItems
function returned bysubscribe
is used in the cleanup ofuseEffect
, 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’sname
ortype
, and clicking it sets some state for thenode
. 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.