React
soundhub has no React-specific package and does not need one. The pattern is small: keep one hub in a module, load the sounds once, and follow the sounds you show with a hook.
A complete app with a music player and overlapping effects is in the
repository at examples/react.
Open it in StackBlitz
to try it without installing anything.
npm install soundhub
One hub for the app
Create the hub on first use and keep it in a module. Do not create it inside a
component: React StrictMode mounts every component twice in development, so you
would get two hubs. Creating it on first use also keeps it out of server
rendering, where there is no AudioContext.
import { SoundHub } from 'soundhub';
export const SOUNDS = [
{ id: 'music', url: '/audio/music.mp3' },
{ id: 'laser', url: '/audio/laser.wav' },
];
let hub: SoundHub | undefined;
let loading: Promise<void> | undefined;
export function getHub(): SoundHub {
hub ??= new SoundHub({ masterLimiter: true });
return hub;
}
/** Loads every sound once, however many components ask for it. */
export function loadSounds(): Promise<void> {
loading ??= getHub().loadSounds(SOUNDS);
return loading;
}
Following a sound with a hook
addEventListener takes a filter, so a hook can
listen to one sound only. It also returns a function that removes the listener,
which is what the effect cleanup needs.
import { useEffect, useState } from 'react';
import { SoundEventsEnum, type SoundStateInfo } from 'soundhub';
import { getHub, loadSounds } from './hub';
export function useSoundsLoaded(): boolean {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let active = true;
loadSounds().then(() => {
if (active) setLoaded(true);
});
return () => {
active = false;
};
}, []);
return loaded;
}
const STATE_EVENTS = [
SoundEventsEnum.STARTED,
SoundEventsEnum.PAUSED,
SoundEventsEnum.RESUMED,
SoundEventsEnum.STOPPED,
SoundEventsEnum.ENDED,
SoundEventsEnum.SEEKED,
SoundEventsEnum.PROGRESS,
];
export function useSoundState(id: string): SoundStateInfo | undefined {
const [state, setState] = useState<SoundStateInfo>();
useEffect(() => {
const hub = getHub();
const update = () => setState(hub.getSoundState(id));
update();
const removers = STATE_EVENTS.map((type) => hub.addEventListener(type, update, { soundId: id }));
return () => removers.forEach((remove) => remove());
}, [id]);
return state;
}
The state is the same object getSoundState returns:
the playback state, currentTime, duration, progress, volume and pan.
A player component
import { SoundState } from 'soundhub';
import { getHub } from './sound/hub';
import { useSoundState } from './sound/hooks';
export function MusicPlayer() {
const state = useSoundState('music');
const playing = state?.state === SoundState.Playing;
const paused = state?.state === SoundState.Paused;
function toggle() {
const hub = getHub();
if (playing) hub.pause('music');
else if (paused) hub.resume('music');
else hub.play('music', { loop: true, fadeInDuration: 1 });
}
return (
<div>
<button onClick={toggle}>{playing ? 'Pause' : 'Play'}</button>
<input
type="range"
min={0}
max={state?.duration || 1}
step={0.1}
value={state?.currentTime ?? 0}
onChange={(e) => getHub().seek('music', Number(e.target.value))}
/>
</div>
);
}
Effects need no state at all. Call the hub from the handler:
<button onClick={() => getHub().play('laser', { overlap: true })}>Fire</button>
With overlap: true every click starts its own instance, so fast clicks do not
cut each other off.
Browsers keep audio silent until the visitor clicks, taps or presses a key. Call
the first play() from an event handler, as above, and soundhub resumes the
audio context for you.
Next.js and other server rendering
The hub uses the Web Audio API, which only exists in the browser. Two rules keep it off the server:
- Only call
getHub()from effects and event handlers, never during render. The hooks above already do this. - In the Next.js App Router, put
'use client'at the top of any file that uses the hooks.
Because getHub() creates the hub on first use, importing hub.ts on the
server is safe. Nothing runs until a browser asks for it.