Building real-time visualizations in React often leads to severe frame drops if rendering state updates directly through the DOM. When handling continuous data streams—like microphone input or audio frequency analysis—triggering React re-renders on every frame will cause massive layout thrashing.
In this tutorial, we will build a 60fps real-time frequency visualizer in Next.js 15 App Router using the Web Audio API and an isolated HTML5 2D Canvas pipeline.
Architecture Overview
To maintain seamless performance:
-
Direct Canvas Rendering: We bypass React's virtual DOM reconciliation loop by handling canvas drawing directly inside a dedicated
requestAnimationFrameloop. -
Web Audio API Stream Analysis: We capture audio via
navigator.mediaDevices.getUserMedia(), route it through anAudioContext, and extract frequency data using anAnalyserNode. - Strict Lifecycle Cleanup: Audio contexts, audio streams, and animation frames must be torn down on unmount to prevent memory leaks and dangling hardware listeners.
1. Creating the Visualizer Component
Create components/AudioVisualizer.tsx:
"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
interface AudioVisualizerProps {
barColor?: string;
barWidth?: number;
gap?: number;
}
export const AudioVisualizer: React.FC<AudioVisualizerProps> = ({
barColor = "#38bdf8",
barWidth = 4,
gap = 2,
}) => {
const canvasRef = useRef<HTMLCanvasElement null |>(null);
const audioContextRef = useRef<AudioContext null |>(null);
const analyserRef = useRef<AnalyserNode null |>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode null |>(null);
const streamRef = useRef<MediaStream null |>(null);
const animationFrameId = useRef<number | null>(null);
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const analyser = analyserRef.current;
if (!canvas || !analyser) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const renderFrame = () => {
animationFrameId.current = requestAnimationFrame(renderFrame);
analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const totalBarWidth = barWidth + gap;
const numBars = Math.floor(canvas.width / totalBarWidth);
const step = Math.floor(bufferLength / numBars);
for (let i = 0; i < numBars; i++) {
const value = dataArray[i * step] || 0;
const percent = value / 255;
const height = canvas.height * percent;
const x = i * totalBarWidth;
const y = canvas.height - height;
ctx.fillStyle = barColor;
ctx.fillRect(x, y, barWidth, height);
}
};
renderFrame();
}, [barColor, barWidth, gap]);
const startVisualizer = async () => {
try {
setError(null);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const AudioCtx =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
const audioCtx = new AudioCtx();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
audioContextRef.current = audioCtx;
analyserRef.current = analyser;
sourceRef.current = source;
setIsListening(true);
draw();
} catch (err) {
setError("Microphone access denied or audio input unavailable.");
}
};
const stopVisualizer = useCallback(() => {
if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current);
if (sourceRef.current) sourceRef.current.disconnect();
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
audioContextRef.current.close();
}
setIsListening(false);
}, []);
useEffect(() => {
return () => {
stopVisualizer();
};
}, [stopVisualizer]);
return (
<div className="flex flex-col items-center justify-center p-6 bg-slate-900 rounded-xl border border-slate-800 w-full max-w-md mx-auto">
<canvas
ref={canvasRef}
width={400}
height={150}
className="w-full bg-slate-950 rounded-lg shadow-inner"
/>
{error && <p className="text-rose-400 text-xs mt-2">{error}</p>}
<button
onClick={isListening ? stopVisualizer : startVisualizer}
className="mt-4 px-4 py-2 text-sm font-medium rounded-md bg-sky-500 hover:bg-sky-400 text-slate-950 transition-colors cursor-pointer"
>
{isListening ? "Stop Microphone" : "Start Visualizer"}
</button>
</div>
);
};
2. Key Implementation Details
Bypassing React State in the Render Loop
Storing high-frequency data arrays in React state (useState) would trigger component re-renders on every animation tick (up to 120 times/sec on high-refresh displays). Using persistent useRef handles keeps calculations completely off the main thread's virtual DOM reconciliation loop.
AnalyserNode Configuration
Setting analyser.fftSize = 256 results in frequencyBinCount = 128 distinct frequency bands. This provides a balance between fine-grained frequency resolution and lightweight rendering calculations.
Audio Resource Teardown
Browser audio streams remain active unless explicitly killed. Cleaning up requires:
- Stopping all active media tracks via
stream.getTracks().forEach(track => track.stop()). - Disconnecting audio routing nodes (
source.disconnect()). - Closing the hardware audio context (
audioContext.close()).
Conclusion
Combining the Web Audio API with isolated HTML5 Canvas components in Next.js 15 provides smooth, hardware-accelerated rendering without sacrificing React architecture principles.
Top comments (0)