Building audio feedback for Zustand state changes
Building audio feedback for Zustand state changes
Making unexpected state changes easier to demonstrate
I started zusound after struggling to demonstrate unexpected state changes to colleagues. When state changed too frequently or behaved in ways I did not expect, I usually relied on console.log to explain what was happening. Walking through those logs together felt indirect and unproductive.
I wanted to add a few lines of code and let colleagues hear the changes while I reproduced a user flow. My hypothesis was that an unusual pattern of sound would give us a more immediate, shared sense of the behavior I was trying to explain.
At the same time, I wanted to understand Zustand’s internals and had been looking for a middleware project. This gave me a concrete experiment to pursue: build audio feedback as middleware and use the implementation to explore how Zustand handles updates.
I also looked at Zustand 4 and 5. From the middleware perspective I was exploring, I did not find a substantial change between them.
Sharing a suspected problem through sound
One demonstration involved a point-cloud visualization receiving data over a WebSocket. I suspected that the state for its text labels was updating more often than necessary. I made those label-state updates audible; the audio was limited to that part of the visualization.
The label text for that operation was delivered once. What we heard was a rapid, staccato burst of roughly 10–20 identical beeps. All the beeps had the same pitch. Their repetition alone was enough to make the update activity stand out and raise the question of why it was happening so many times.
After hearing the demonstration, colleagues agreed that the behavior warranted attention and asked me to investigate and work on a fix. This let us discuss a suspected problem before I had prepared a detailed explanation of it.
The sound gave us a way to notice the behavior together and check whether others also found it unusual. Identifying the cause of the repeated updates and checking their effect on rendering required a separate investigation. I could have shown console output or render highlighting, but I found those signals harder to follow during a demonstration.
Giving different changes different sounds
I used state diffs to give boolean, string, and numeric changes different sounds.
In the earliest version, I also implemented a mapping that raised the pitch as updates became more frequent. The aim was to help a listener recognize recurring patterns and notice when the sound became unusual.
Observing updates through middleware
In the 0.3.1 source snapshot, the middleware subscribes to store updates and compares the previous and current states. It describes each detected change with its top-level key, operation, value type, and old and new values. That gives the audio layer information to work with beyond the fact that an update occurred.
The middleware can be attached when creating a store:
import { create } from 'zustand';
import { zusound } from 'zusound';
const useCounterStore = create<{ count: number; increment: () => void }>()(
zusound(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
{ enabled: true, volume: 0.15 }
)
);
// Call from a button's click or tap handler.
function incrementFromUserGesture() {
useCounterStore.getState().increment();
}
// Call when permanently disposing of this store.
function disposeCounterAudio() {
useCounterStore.zusoundCleanup();
}
I enable audio explicitly in this example and start at a low volume. The default is on in recognized development and test environments, and off in production or unknown environments. The store remains usable after zusoundCleanup() detaches the audio subscription.
The change detector examines keys from both states to detect additions, removals, and updates. It checks reference and shallow equality first, with additional comparisons for more complex values. Changes inside a nested value are reported at its top-level key rather than as a detailed sequence of nested edits. Mutating a nested object in place can leave those comparisons with the same reference, so changed objects and arrays should be replaced immutably.
The audio mapping gives booleans, numbers, and strings different starting pitches and sound characteristics. It also accounts for the change operation and the state key. In this snapshot, numeric updates adjust pitch according to the direction and relative magnitude of the value change; this differs from the frequency-based mapping in the earliest version. A store notification can produce several change records and therefore several audio cues; those cues are not a count of React renders.
Keeping the signal interpretable
The implementation offers optional debouncing. It batches pending changes and retains the latest change for each key before playing them. That reduces the number of audio cues, but it also changes the timing and repetition a listener hears. This matters when the purpose of a demonstration is to make rapid updates noticeable.
The audio engine reuses a lazily created AudioContext and attempts to resume it when suspended. Playback remains subject to browser audio policies. It schedules sounds against the audio clock; the JavaScript work of comparing states and preparing sounds still has a cost. Cleanup removes subscriptions, clears pending work, and releases audio resources when they are no longer in use.
The demonstration with my colleagues showed that audio could help us agree to inspect a particular behavior. It does not establish how reliably people can interpret every sound mapping or identify problems in other applications.
Considering sonification beyond Zustand
The audio layer works from a diff, so I began considering whether other sources of changes could supply the same input. That would let me explore diff sonification beyond Zustand.