Zustand state being triggered server side even though I have "use client"?

Hey all! Recently switched over to Zustand, and quite like it, but I have an issue where my stores' create functions are being called on the server side for some reason?
'use client';

import { create } from 'zustand';
import { setupAudio } from '@/lib/audio';
import { clearBufferedPlayerNodeBuffer } from '@/lib/audio/playback';
import { StarlightWebSocketRequestType, StopAudioRequest } from 'websocket/types';
import { useWebsocketStore } from '../websocket-store';
import { useTranscriptionStore } from './transcription-store';

type PlaybackStore = {
audioContext: AudioContext | null;
bufferedPlayerNode: AudioWorkletNode | null;
gainNode: GainNode | null;
socketState: string;
volume: number;
setVolume: (volume: number) => void;
clearAudio: () => void;
setup: () => void;
};

export const usePlaybackStore = create<PlaybackStore>((set, get) => {
let initialVolume = 0.75;
if (typeof localStorage !== 'undefined') {
initialVolume = localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 0.75;
}

return {
audioContext: null,
bufferedPlayerNode: null,
gainNode: null,
socketState: '',
volume: initialVolume,
setVolume: (desiredVolume: number) => {
const { gainNode } = get();
if (!gainNode) return;

const result = Math.max(0, Math.min(1, desiredVolume));
gainNode.gain.value = result;
set({ volume: result });

if (typeof localStorage !== 'undefined') {
localStorage.setItem('volume', result.toString());
}
},
clearAudio: () => {
const sendToServer = useWebsocketStore.getState().sendToServer;
sendToServer({
type: StarlightWebSocketRequestType.stopAudio,
data: {},
} as StopAudioRequest);

const { bufferedPlayerNode } = get();
clearBufferedPlayerNodeBuffer(bufferedPlayerNode);
},
setup: async () => {
const { audioContext, bufferedPlayerNode, gainNode } = await setupAudio();

gainNode.gain.value = initialVolume;

set({ audioContext, bufferedPlayerNode, gainNode });

useTranscriptionStore.getState().setupAudioRecorder();
},
};
});
'use client';

import { create } from 'zustand';
import { setupAudio } from '@/lib/audio';
import { clearBufferedPlayerNodeBuffer } from '@/lib/audio/playback';
import { StarlightWebSocketRequestType, StopAudioRequest } from 'websocket/types';
import { useWebsocketStore } from '../websocket-store';
import { useTranscriptionStore } from './transcription-store';

type PlaybackStore = {
audioContext: AudioContext | null;
bufferedPlayerNode: AudioWorkletNode | null;
gainNode: GainNode | null;
socketState: string;
volume: number;
setVolume: (volume: number) => void;
clearAudio: () => void;
setup: () => void;
};

export const usePlaybackStore = create<PlaybackStore>((set, get) => {
let initialVolume = 0.75;
if (typeof localStorage !== 'undefined') {
initialVolume = localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 0.75;
}

return {
audioContext: null,
bufferedPlayerNode: null,
gainNode: null,
socketState: '',
volume: initialVolume,
setVolume: (desiredVolume: number) => {
const { gainNode } = get();
if (!gainNode) return;

const result = Math.max(0, Math.min(1, desiredVolume));
gainNode.gain.value = result;
set({ volume: result });

if (typeof localStorage !== 'undefined') {
localStorage.setItem('volume', result.toString());
}
},
clearAudio: () => {
const sendToServer = useWebsocketStore.getState().sendToServer;
sendToServer({
type: StarlightWebSocketRequestType.stopAudio,
data: {},
} as StopAudioRequest);

const { bufferedPlayerNode } = get();
clearBufferedPlayerNodeBuffer(bufferedPlayerNode);
},
setup: async () => {
const { audioContext, bufferedPlayerNode, gainNode } = await setupAudio();

gainNode.gain.value = initialVolume;

set({ audioContext, bufferedPlayerNode, gainNode });

useTranscriptionStore.getState().setupAudioRecorder();
},
};
});
Take this AudioContext store for example, I had to add the typeof checks because otherwise I would get localStorage doesn't exist. My current theory is that it's being run on the server side, but maybe it's just running before localStorage is mounted (if that's a thing?) I trigger the stores from a component
'use client';

import { useEffect } from 'react';
import { useWebsocketStore } from '@/stores/websocket-store';
import { usePlaybackStore } from '@/stores/audio/playback-store';

export function StoreInitializer() {
useEffect(() => {
useWebsocketStore.getState().connect();
usePlaybackStore.getState().setup();
}, []);

return null;
}
'use client';

import { useEffect } from 'react';
import { useWebsocketStore } from '@/stores/websocket-store';
import { usePlaybackStore } from '@/stores/audio/playback-store';

export function StoreInitializer() {
useEffect(() => {
useWebsocketStore.getState().connect();
usePlaybackStore.getState().setup();
}, []);

return null;
}
that sits in my layout
12 Replies
Josh
Josh•12mo ago
Use client is very misleading. There are 3 different ways react is loaded/run in next. The first is server components (no state, effects, etc). The second is use client which is wildly misunderstood to run only on the client. Use client files are still fully server side rendered, so during the SSR period that code is still technically running on your server for the initial render The third type is client only, which you can do via dynamically importing a module and telling next to turn off SSR for that component
Harris
Harris•12mo ago
i see, what would be the best approach in regards to wanting zustand to only want client side?
Josh
Josh•12mo ago
And during this SSR period, local storage, window, document, etc are all underground Well that's the thing, you still want to make everything be server friendly as much as possible cause it helps your initial page loads
Harris
Harris•12mo ago
that's true, but in this case I'm trying to handle a websocket & audiocontext which only really works client side I think? I don't think those can communicate nicely between boundary layers same with localstorage oh! wait i guess I move my localStorage code out of zustand and into the store initalizer component that has my useEffect
Josh
Josh•12mo ago
Correct, so to solve this, simply move your initial volume into a effect and make it a state variable and have it run on load. Anything inside a useEffect will not run during ssr
Harris
Harris•12mo ago
beautiful, will try that out right now, ty
Josh
Josh•12mo ago
Correct as in websockets are client side and such
Harris
Harris•12mo ago
need to really deep dive the use client stuff more for a better understanding
Josh
Josh•12mo ago
It's very messy But once you really have the mental model down its very nice in terms of how much is done under the hood for you in terms of perf and such Also In this case it might make sense for you to just try and import that entire effect dynamically and turn off SSR since it really does directly depend on the client
Harris
Harris•12mo ago
I ended up moving it into my setupAudio function which only runs client side based on the store init effect
import { setupBufferedPlayerProcessor } from './playback';

export async function setupAudio() {
const audioContext = new AudioContext({
sampleRate: 44100,
latencyHint: 'interactive',
});

// Setup AudioWorklet for buffered streaming playback
const blobURL = setupBufferedPlayerProcessor();
await audioContext.audioWorklet.addModule(blobURL);

const bufferedPlayerNode = new AudioWorkletNode(audioContext, 'buffered-player-processor');

// Gain node for volume control / mute
const gainNode = audioContext.createGain();

bufferedPlayerNode.connect(gainNode);
gainNode.connect(audioContext.destination);

// Retrieve browser volume from local storage
let volume = localStorage?.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 0.75;

gainNode.gain.value = volume;

return {
audioContext,
bufferedPlayerNode,
gainNode,
volume,
};
}
import { setupBufferedPlayerProcessor } from './playback';

export async function setupAudio() {
const audioContext = new AudioContext({
sampleRate: 44100,
latencyHint: 'interactive',
});

// Setup AudioWorklet for buffered streaming playback
const blobURL = setupBufferedPlayerProcessor();
await audioContext.audioWorklet.addModule(blobURL);

const bufferedPlayerNode = new AudioWorkletNode(audioContext, 'buffered-player-processor');

// Gain node for volume control / mute
const gainNode = audioContext.createGain();

bufferedPlayerNode.connect(gainNode);
gainNode.connect(audioContext.destination);

// Retrieve browser volume from local storage
let volume = localStorage?.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 0.75;

gainNode.gain.value = volume;

return {
audioContext,
bufferedPlayerNode,
gainNode,
volume,
};
}
'use client';

import { create } from 'zustand';
import { setupAudio } from '@/lib/audio';
import { clearBufferedPlayerNodeBuffer } from '@/lib/audio/playback';
import { StarlightWebSocketRequestType, StopAudioRequest } from 'websocket/types';
import { useWebsocketStore } from '../websocket-store';
import { useTranscriptionStore } from './transcription-store';

type PlaybackStore = {
audioContext: AudioContext | null;
bufferedPlayerNode: AudioWorkletNode | null;
gainNode: GainNode | null;
socketState: string;
volume: number | null;
setVolume: (volume: number) => void;
clearAudio: () => void;
setup: () => void;
};

export const usePlaybackStore = create<PlaybackStore>((set, get) => {
return {
audioContext: null,
bufferedPlayerNode: null,
gainNode: null,
socketState: '',
volume: null,
setVolume: (desiredVolume: number) => {
const { gainNode } = get();
if (!gainNode) return;

const result = Math.max(0, Math.min(1, desiredVolume));
gainNode.gain.value = result;
set({ volume: result });

localStorage.setItem('volume', result.toString());
},
clearAudio: () => {
const sendToServer = useWebsocketStore.getState().sendToServer;
sendToServer({
type: StarlightWebSocketRequestType.stopAudio,
data: {},
} as StopAudioRequest);

const { bufferedPlayerNode } = get();
clearBufferedPlayerNodeBuffer(bufferedPlayerNode);
},
setup: async () => {
const { audioContext, bufferedPlayerNode, gainNode, volume } = await setupAudio();

set({ audioContext, bufferedPlayerNode, gainNode, volume });

useTranscriptionStore.getState().setupAudioRecorder();
},
};
});
'use client';

import { create } from 'zustand';
import { setupAudio } from '@/lib/audio';
import { clearBufferedPlayerNodeBuffer } from '@/lib/audio/playback';
import { StarlightWebSocketRequestType, StopAudioRequest } from 'websocket/types';
import { useWebsocketStore } from '../websocket-store';
import { useTranscriptionStore } from './transcription-store';

type PlaybackStore = {
audioContext: AudioContext | null;
bufferedPlayerNode: AudioWorkletNode | null;
gainNode: GainNode | null;
socketState: string;
volume: number | null;
setVolume: (volume: number) => void;
clearAudio: () => void;
setup: () => void;
};

export const usePlaybackStore = create<PlaybackStore>((set, get) => {
return {
audioContext: null,
bufferedPlayerNode: null,
gainNode: null,
socketState: '',
volume: null,
setVolume: (desiredVolume: number) => {
const { gainNode } = get();
if (!gainNode) return;

const result = Math.max(0, Math.min(1, desiredVolume));
gainNode.gain.value = result;
set({ volume: result });

localStorage.setItem('volume', result.toString());
},
clearAudio: () => {
const sendToServer = useWebsocketStore.getState().sendToServer;
sendToServer({
type: StarlightWebSocketRequestType.stopAudio,
data: {},
} as StopAudioRequest);

const { bufferedPlayerNode } = get();
clearBufferedPlayerNodeBuffer(bufferedPlayerNode);
},
setup: async () => {
const { audioContext, bufferedPlayerNode, gainNode, volume } = await setupAudio();

set({ audioContext, bufferedPlayerNode, gainNode, volume });

useTranscriptionStore.getState().setupAudioRecorder();
},
};
});
and then just set the zustand store volume based on what the client returns and it works great! no error 🙂 ty for the pointers
Josh
Josh•12mo ago
Woo! Great stuff! Glad I could help If that's everything feel free to mark the question as solved
Harris
Harris•12mo ago
already did! 😄
Want results from more Discord servers?
Add your server