
Aug 31, 2025 โข 4 min Read
The Secret to Smooth WebGL Shaders in React:A Performance Deep Dive
Discover how to achieve performant WebGL shaders in React by separating shader setup from uniform updates.
Interactive Shader Blob
Here's an interactive shader blob component embedded in this blog post:

You can interact with it above! The shader creates beautiful animated gradients.
Interactive GLSL shaders can bring a website to life, transforming a static page into a dynamic, fluid experience. Theyโre amazing to look at and elevate a siteโs game to a whole new level. But connecting them to React's declarative state management can quickly turn a beautiful animation into a stuttering, performance-killing mess. I recently faced this exact problem while building a component with a live shader background that needed to change colors based on user input in Next.js.
The result was pretty underwhelming. Yes, the interactivity WORKED, but it was far from smooth ๐. Dragging the color picker caused the animation to lag horribly, revealing a critical flaw in how I was bridging the gap between React's state and the shader's rendering loop.
The Intuitive but Flawed Approach
My goal was to feed the shader's "uniforms" variables you can update from JavaScript with colors from my React state. The useEffect
hook seemed like the perfect tool for this, as it's designed to react to changes in state.
This led me to what felt like a logical structure:
'use client';
import { useState, useEffect, useRef } from 'react';
function MyShaderComponent() {
const [color1, setColor1] = useState('#ff5eef');
const [color2, setColor2] = useState('#1a8cff');
const canvasRef = useRef(null);
useEffect(() => {
// 1. Get the canvas and WebGL context
const canvas = canvasRef.current;
const gl = canvas.getContext('webgl');
// 2. Compile shaders (Expensive!)
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
// 3. Link the WebGL program (Quite Heavy)
const program = createProgram(gl, vertexShader, fragmentShader);
// 4. Set up an animation loop
const render = (time) => {
// ... drawing logic ...
gl.uniform3fv(uniforms.col1, hexToRgb(color1)); // Use state directly
gl.uniform3fv(uniforms.col2, hexToRgb(color2)); // Use state directly
requestAnimationFrame(render);
};
requestAnimationFrame(render);
// This is where things go wrong ๐: re-run the entire setup when colors change
}, [color1, color2]);
return (
<div>
<input type="color" value={color1} onChange={(e) => setColor1(e.target.value)} />
<input type="color" value={color2} onChange={(e) => setColor2(e.target.value)} />
<canvas ref={canvasRef} />
</div>
);
}
This code works, but it hides a massive performance bottleneck specific to graphics programming.
The Problem: Re-building the house to change a lightbulb
The dependency array [color1, color2]
tells React to re-run the entire effect whenever the colors change. For a shader, this is wild. The effect isn't just updating a value; it's performing several computationally expensive, one-time setup tasks:
- Compiling GLSL shaders: Parsing text and turning it into a program the GPU can execute.
- Linking a WebGL program: Connecting the compiled shaders into a single, executable graphics pipeline.
Every time the color picker moved, it triggered a state update, causing React to tear down the old WebGL program and recompile and relink the entire shader from scratch.
The lag was the direct result of forcing the browser to do this heavy setup work dozens of times per second.
The Fix: Separate the Shader Setup from Uniform Updates
The key to high-performance shader interactivity in React is to strictly separate the expensive, one-time setup from the frequent, lightweight updates.
We can achieve this by using multiple useEffect
hooks for different purposes and using a useRef
to bridge the gap between React's state and the continuous animation loop.
1. useEffect
for One-Time Setup: This effect runs only once when the component mounts ([]
dependency array). Its sole job is to do the heavy lifting: get the canvas, compile shaders, and link the program.
2. useEffect
for the Animation Loop: This effect also runs only once. It starts the requestAnimationFrame
loop, which will run independently of React's render cycle.
3. useRef
as the Bridge: An animation loop callback is only created once, so it will have a "stale closure" over the color1
and color2
state variables (in simple words, the old values ๐คท). To solve this, we store our colors in a useRef
. This gives us a stable object whose current
property we can update from React and safely read from inside the animation loop to get the latest values.
Here's the corrected, performant structure:
'use client';
import { useState, useEffect, useRef } from 'react';
function MyShaderComponent() {
const [color1, setColor1] = useState('#ff5eef');
const [color2, setColor2] = useState('#1a8cff');
const canvasRef = useRef(null);
const webGLProgramRef = useRef(null);
const colorValuesRef = useRef({ color1, color2 });
// This lightweight effect keeps the ref in sync with the state
useEffect(() => {
colorValuesRef.current = { color1, color2 };
}, [color1, color2]);
// Effect for ONE-TIME WebGL setup
useEffect(() => {
const canvas = canvasRef.current;
const gl = canvas.getContext('webgl');
// ... All the expensive shader compiling and program linking ...
// Store the final, ready-to-use program in a ref.
webGLProgramRef.current = { gl, program, uniforms };
}, []); // Empty dependency array means this runs only ONCE.
// Effect for the Animation Loop
useEffect(() => {
// Wait until setup is complete
if (!webGLProgramRef.current) return;
const { gl, program, uniforms } = webGLProgramRef.current;
let animationFrameId;
const render = (time) => {
gl.useProgram(program);
// Bridge: Read the LATEST colors from the ref in every frame
const { color1: c1, color2: c2 } = colorValuesRef.current;
gl.uniform3fv(uniforms.col1, hexToRgb(c1));
gl.uniform3fv(uniforms.col2, hexToRgb(c2));
// ... other drawing logic ...
animationFrameId = requestAnimationFrame(render);
};
requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, []); // Also runs only ONCE.
return (
<div>
<input type="color" value={color1} onChange={(e) => setColor1(e.target.value)} />
<input type="color" value={color2} onChange={(e) => setColor2(e.target.value)} />
<canvas ref={canvasRef} />
</div>
);
}
So, whatโs the takeaway?
- Compile Shaders Once: Shader compilation and program linking are expensive, one-time setup costs. They must be isolated in a
useEffect
with an empty[]
dependency array. - Decouple the Render Loop: The
requestAnimationFrame
loop should be independent of React's re-renders. Start it once and let it run. - Use
useRef
to Bridge State to Uniforms: AuseRef
is the perfect tool to pass data from React's declarative state into an independent, imperative animation loop without triggering expensive effects or reading stale values.
The result is a silky-smooth, performant shader that reacts instantly to user input, unlocking the true potential of interactive graphics in a React app.
OR, you could simply use Astro ๐ next time ๐.