World-class React hooks for browser media device control (camera, microphone, screen share, WebRTC)
npm install @classytic/react-stream

Production-ready React 19 hooks for building pro-tier video apps (Discord, Teams, Meet).
Built on useSyncExternalStore for perfect hydration and zero-tearing state management.
- Installation
- Quick Start
- Architecture
- Core Hooks
- WebRTC Integration
- Common Pitfalls
- API Reference
- Browser Support
---
``bash`
pnpm add @classytic/react-streamor
npm install @classytic/react-stream
---
`tsx
import { useMediaManager } from "@classytic/react-stream";
function Room() {
const {
camera,
microphone,
cameraStream,
isInitialized,
initialize,
toggleCamera,
toggleMicrophone,
switchAudioDevice,
switchVideoDevice,
getVideoTrack,
getAudioTrack,
} = useMediaManager();
return (
---
Architecture
$3
This library uses React 19's
useSyncExternalStore for state management, ensuring:- Zero tearing - State is always consistent across concurrent renders
- SSR-safe - Proper hydration with
getServerSnapshot
- Granular subscriptions - Only re-render components that need updates`
┌─────────────────────────────────────────────────────────────────┐
│ MediaStore │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Microphone │ │ Camera │ │ Screen │ │
│ │ Stream │ │ Stream │ │ Share │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ useSyncExternalStore │ │
│ └─────────────────────┘ │
│ │ │
└─────────────────────────┼───────────────────────────────────────┘
▼
┌─────────────────────┐
│ React Components │
└─────────────────────┘
`$3
All action callbacks (
toggleCamera, toggleMicrophone, etc.) are stable references that never change. This means:- Safe to use in
useEffect dependencies
- Safe to pass to child components without useCallback wrapper
- No infinite re-render loops from callback identity changes`tsx
// ✅ Safe - callbacks are stable
useEffect(() => {
if (someCondition) {
toggleCamera();
}
}, [someCondition, toggleCamera]); // toggleCamera never changes
`$3
The library uses refs internally for mutable state that shouldn't trigger re-renders:
`tsx
// Internal pattern - refs for control flow, state for UI
const isActiveRef = useRef(false); // For internal checks
const [isActive, setIsActive] = useState(false); // For UI updates
`---
Core Hooks
$3
The main orchestration hook for camera, microphone, and screen share.
`tsx
const {
// State (reactive - triggers re-renders)
camera, // { status, stream, trackEnabled, error }
microphone, // { status, stream, trackEnabled, error }
screen, // { status, stream, trackEnabled, error }
cameraStream, // MediaStream | null
screenStream, // MediaStream | null
audioLevel, // number (0-100)
isSpeaking, // boolean
isInitialized, // boolean
isInitializing, // boolean // Actions (stable - never change identity)
initialize, // () => Promise
toggleMicrophone, // () => void
toggleCamera, // () => Promise
toggleScreenShare,// () => Promise
switchAudioDevice,// (deviceId: string) => Promise
switchVideoDevice,// (deviceId: string) => Promise
cleanup, // () => void
// Track access (for WebRTC)
getVideoTrack, // () => MediaStreamTrack | null
getAudioTrack, // () => MediaStreamTrack | null
} = useMediaManager(options);
`Options:
`tsx
interface UseMediaManagerOptions {
videoConstraints?: MediaTrackConstraints | false; // false = no camera
audioConstraints?: MediaTrackConstraints | false; // false = no mic
screenShareOptions?: DisplayMediaStreamOptions;
autoInitialize?: boolean; // Auto-request permissions on mount // Callbacks
onMicrophoneChange?: (state: DeviceState) => void;
onCameraChange?: (state: DeviceState) => void;
onScreenShareChange?: (state: DeviceState) => void;
onAudioLevel?: (data: AudioLevelData) => void;
onError?: (type: MediaDeviceType, error: Error) => void;
}
`$3
Enumerate available media devices.
`tsx
const {
videoInputs, // DeviceInfo[] - cameras
audioInputs, // DeviceInfo[] - microphones
audioOutputs, // DeviceInfo[] - speakers
allDevices, // DeviceInfo[] - all devices
isLoading, // boolean
error, // Error | null
refresh, // () => Promise
} = useDevices();
`$3
Real-time audio level monitoring with voice activity detection.
`tsx
const {
level, // number (0-100) - normalized audio level
raw, // number - raw FFT average
isSpeaking, // boolean - above threshold
isActive, // boolean - analyzer running
start, // () => void
stop, // () => void
} = useAudioAnalyzer(stream, {
fftSize: 256,
smoothingTimeConstant: 0.8,
speakingThreshold: 5,
updateInterval: 100, // ms
});
`---
WebRTC Integration
$3
`tsx
import { useMediaManager } from "@classytic/react-stream";
import { useLocalParticipant, useTracks } from "@livekit/components-react";function LiveKitRoom() {
const { getVideoTrack, getAudioTrack, toggleCamera, toggleMicrophone } =
useMediaManager({ autoInitialize: true });
const { localParticipant } = useLocalParticipant();
// Publish tracks to LiveKit
useEffect(() => {
const videoTrack = getVideoTrack();
const audioTrack = getAudioTrack();
if (videoTrack && localParticipant) {
localParticipant.publishTrack(videoTrack, { name: 'camera' });
}
if (audioTrack && localParticipant) {
localParticipant.publishTrack(audioTrack, { name: 'microphone' });
}
}, [localParticipant, getVideoTrack, getAudioTrack]);
return (
);
}
`$3
`tsx
import { useMediaManager } from "@classytic/react-stream";function WebRTCCall() {
const { getVideoTrack, getAudioTrack, isInitialized } = useMediaManager();
const pcRef = useRef(null);
const sendersRef = useRef
// Setup peer connection
useEffect(() => {
pcRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
return () => pcRef.current?.close();
}, []);
// Add tracks when ready
useEffect(() => {
if (!isInitialized || !pcRef.current) return;
const videoTrack = getVideoTrack();
const audioTrack = getAudioTrack();
if (videoTrack) {
const sender = pcRef.current.addTrack(videoTrack);
sendersRef.current.set('video', sender);
}
if (audioTrack) {
const sender = pcRef.current.addTrack(audioTrack);
sendersRef.current.set('audio', sender);
}
}, [isInitialized, getVideoTrack, getAudioTrack]);
// Handle track replacement (e.g., device switch)
const replaceTrack = async (kind: 'video' | 'audio') => {
const sender = sendersRef.current.get(kind);
const track = kind === 'video' ? getVideoTrack() : getAudioTrack();
if (sender && track) {
await sender.replaceTrack(track);
}
};
return
...;
}
`$3
`tsx
import { useMediaManager } from "@classytic/react-stream";
import { useDaily } from "@daily-co/daily-react";function DailyRoom() {
const daily = useDaily();
const { getVideoTrack, getAudioTrack, switchVideoDevice } = useMediaManager();
// Update Daily when tracks change
useEffect(() => {
if (!daily) return;
const videoTrack = getVideoTrack();
if (videoTrack) {
daily.setLocalVideo(true);
}
}, [daily, getVideoTrack]);
// Switch camera
const handleCameraSwitch = async (deviceId: string) => {
await switchVideoDevice(deviceId);
// Daily will automatically pick up the new track
};
return
...;
}
`---
Common Pitfalls
$3
Problem: Creating
new MediaStream() during render causes infinite loops.`tsx
// ❌ BAD - creates new MediaStream every render
function BadExample({ track }) {
const stream = new MediaStream([track]); // New object every render!
return ;
}// ✅ GOOD - memoize the MediaStream
function GoodExample({ track }) {
const stream = useMemo(
() => track ? new MediaStream([track]) : null,
[track]
);
return ;
}
`$3
Problem: Inline objects change identity every render.
`tsx
// ❌ BAD - options object changes every render
function BadExample() {
const { level } = useAudioAnalyzer(stream, {
fftSize: 256, // New object every render!
});
}// ✅ GOOD - stable options reference
const ANALYZER_OPTIONS = { fftSize: 256 };
function GoodExample() {
const { level } = useAudioAnalyzer(stream, ANALYZER_OPTIONS);
}
// ✅ ALSO GOOD - useMemo for dynamic options
function GoodExample2({ fftSize }) {
const options = useMemo(() => ({ fftSize }), [fftSize]);
const { level } = useAudioAnalyzer(stream, options);
}
`$3
Problem: Media tracks keep running after component unmount.
`tsx
// ❌ BAD - tracks leak
function BadExample() {
const { initialize } = useMediaManager();
useEffect(() => { initialize(); }, []);
// No cleanup!
}// ✅ GOOD - cleanup in useEffect
function GoodExample() {
const { initialize, cleanup } = useMediaManager();
useEffect(() => {
initialize();
return () => cleanup(); // Stop tracks on unmount
}, [initialize, cleanup]);
}
`$3
Problem: Using state in a callback's dependencies when that callback sets the same state.
`tsx
// ❌ BAD - infinite loop
const start = useCallback(() => {
if (isActive) return; // Depends on isActive
setIsActive(true); // Sets isActive
}, [isActive]); // isActive changes → start changes → effect runs// ✅ GOOD - use ref for internal checks
const isActiveRef = useRef(false);
const start = useCallback(() => {
if (isActiveRef.current) return; // Check ref
isActiveRef.current = true;
setIsActive(true); // State for UI only
}, []); // Stable callback
`$3
Problem: Camera/mic gets unplugged but UI doesn't update.
`tsx
// ✅ GOOD - handle device changes
function GoodExample() {
const { camera, microphone } = useMediaManager({
autoSwitchDevices: true, // Auto-reacquire on disconnect
onError: (type, error) => {
console.error(${type} error:, error);
// Show user notification
},
}); // Check for ended tracks
if (camera.status === 'error') {
return
Camera disconnected: {camera.error};
}
}
`$3
Problem: Forgetting to include system audio in screen share.
`tsx
// ✅ GOOD - request system audio
const { startScreenShare } = useMediaManager({
screenShareOptions: {
video: true,
audio: true, // Include system audio (tab audio)
},
});
`---
API Reference
$3
`tsx
type DeviceStatus =
| 'idle' // Not started
| 'acquiring' // Requesting permission
| 'active' // Track is live and enabled
| 'muted' // Track is live but disabled
| 'stopped' // Track was stopped (camera off)
| 'error'; // Error occurred
`$3
`tsx
interface DeviceState {
status: DeviceStatus;
stream: MediaStream | null;
trackEnabled: boolean;
error: string | null;
}
`$3
`tsx
// Only import what you need for smaller bundles
import { useDevices } from '@classytic/react-stream/devices';
import { useConstraints, QUALITY_PRESETS } from '@classytic/react-stream/constraints';
import { useScreenShare } from '@classytic/react-stream/screen';
import { useAudioAnalyzer } from '@classytic/react-stream/audio';
import { useTrackPublisher } from '@classytic/react-stream/webrtc';
import { useNoiseSuppression } from '@classytic/react-stream/fx/audio';
import { useWorkerProcessor } from '@classytic/react-stream/fx/processor';
import { MediaProvider, useMediaContext } from '@classytic/react-stream/context';
`---
AI & Processing
$3
`tsx
import { useNoiseSuppression } from "@classytic/react-stream/fx/audio";function NoiseControl({ micTrack }) {
const ns = useNoiseSuppression({
wasmUrl: "/models/rnnoise.wasm",
onReady: () => console.log("NS ready"),
onError: (err) => console.error(err),
});
// Start processing
const enableNS = () => ns.start(micTrack);
// Use ns.processedTrack for WebRTC
return (
);
}
`$3
`tsx
import { useWorkerProcessor } from "@classytic/react-stream/fx/processor";function BackgroundBlur({ videoTrack }) {
const processor = useWorkerProcessor({
workerUrl: "/workers/blur-worker.js",
config: { blurRadius: 15 },
onReady: () => console.log("Worker ready"),
});
// processor.processedTrack is the blurred video
return (
);
}
`---
Browser Support
| Feature | Chrome | Firefox | Safari | Edge |
| ----------------- | ------ | ------- | ------ | ---- |
| Core Media | 74+ | 66+ | 14+ | 79+ |
| Worker Processing | 94+ | - | - | 94+ |
| WebTransport | 97+ | 114+ | - | 97+ |
| WebCodecs | 94+ | 130+ | 16.4+ | 94+ |
| AudioWorklet | 66+ | 76+ | 14.1+ | 79+ |
---
Debug Mode
Enable debug logging to see internal state changes:
`tsx
import { enableDebug, disableDebug } from "@classytic/react-stream";// In development
if (process.env.NODE_ENV === 'development') {
enableDebug();
}
// Or enable specific loggers
enableDebug('useMediaManager');
enableDebug('createMediaStore');
``---
MIT