Why requestAnimationFrame Over setInterval or setTimeout?
Traditional timing approaches like setInterval and setTimeout operate independently of the browser's refresh cycle. This disconnect creates several problems: animations may skip frames, cause visual stuttering, continue running in background tabs wasting CPU resources, and drain battery life on mobile devices.
The requestAnimationFrame method addresses these issues by synchronizing callback execution with the browser's repaint cycle. When you request an animation frame, the browser calls your callback right before the next screen repaint, typically 60 times per second on most displays.
Key benefits:
- Synchronized with browser's refresh rate
- Automatically pauses in background tabs
- Frame-independent animations with timestamp support
- Optimized for battery life and performance
For developers building modern web applications, understanding the browser's rendering cycle is essential. Our web development services help teams implement performant animations and optimize user interfaces. When animations are properly synchronized with the browser, they create fluid experiences that delight users without draining battery life on mobile devices.
According to the MDN Web Docs, this synchronization ensures animations render at optimal times, eliminating wasted cycles and providing smoother visual results.
useRef: More Than Just DOM References
The useRef hook serves a critical purpose beyond accessing DOM elements. It creates a mutable container that persists across component re-renders without triggering additional renders when its value changes.
Three ways to store variables in functional components:
- Simple const/let - Reinitialized on every render
- useState - Persists across renders, triggers re-render on change
- useRef - Persists across renders, no re-render on change
// Problem: counter resets on every render
function Counter() {
let count = 0; // Reset to 0 each render
return <div onClick={() => count++}>{count}</div>;
}
// Better: useState triggers re-render
function CounterWithState() {
const [count, setCount] = useState(0);
return <div onClick={() => setCount(count + 1)}>{count}</div>;
}
// Best for animation: useRef - persists, no re-render
function AnimationComponent() {
const requestRef = useRef(); // Persists, no re-render
const previousTimeRef = useRef();
useEffect(() => {
const animate = (time) => {
if (previousTimeRef.current !== undefined) {
// Use values without triggering renders
console.log('Frame delta:', time - previousTimeRef.current);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []);
return null;
}
For animation code, store these in refs: the request ID returned by requestAnimationFrame, the previous frame's timestamp for calculating elapsed time, and any intermediate animation values not yet ready for UI display. When combined with modern React data fetching patterns, these techniques create responsive, animated interfaces that feel instantaneous to users.
useEffect: Initializing and Cleaning Up Animations
The useEffect hook manages the animation lifecycle in React. Animations need to start when a component mounts and stop when it unmounts.
The pattern:
useEffect(() => {
const requestId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestId);
}, []);
Key points:
- Empty dependency array
[]ensures the effect runs only once - Cleanup function prevents memory leaks
- Avoids creating multiple animation requests during render cycles
The closure challenge: When the dependency array is empty, animation callbacks capture initial state values. This creates "stale closures" where your callback can't see updated state.
// PROBLEM: stale closure
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('Count:', count); // Always 0!
setCount(count + 1); // Always sets 1!
}, 1000);
return () => clearInterval(timer);
}, []); // Empty = captures initial count = 0
return <div>{count}</div>; // Shows 1, then stops
}
The interval callback captured count = 0 when the effect ran. Even though count updates, the callback never sees the new value. The same problem affects requestAnimationFrame callbacks in useEffect.
Functional State Updates: Avoiding Stale Closures
When the effect's dependency array is empty, the animation callback can't see current state values. The solution: use the functional update form of state setters.
Don't do this:
setCounter(counter + 1); // Stale closure - counter is initial value
Do this instead:
setCounter(prevCounter => prevCounter + 1); // Always gets latest value
Complete example with smooth counter animation:
function AnimatedCounter({ endValue = 100, duration = 2000 }) {
const [displayValue, setDisplayValue] = useState(0);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
if (!startTimeRef.current) startTimeRef.current = currentTime;
const elapsed = currentTime - startTimeRef.current;
const progress = Math.min(elapsed / duration, 1);
// Functional update - always gets latest state
setDisplayValue(prev => Math.floor(progress * endValue));
if (progress < 1) {
requestAnimationFrame(animate);
}
};
const requestId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestId);
}, [endValue, duration]);
return <div className="counter">{displayValue}</div>;
}
This pattern ensures you always work with the latest state value regardless of closure timing, whether you're incrementing counters, calculating positions, or applying easing functions.
Building a Custom Animation Hook
Custom hooks encapsulate animation logic into reusable utilities. A well-designed hook hides implementation details while provides a clean interface for your components.
The useAnimationFrame pattern:
function useAnimationFrame(callback) {
const requestRef = useRef();
const previousTimeRef = useRef();
useEffect(() => {
const animate = (time) => {
if (previousTimeRef.current !== undefined) {
callback(time, time - previousTimeRef.current);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, [callback]);
}
Benefits:
- Separates animation lifecycle from animation logic
- Reusable across multiple components
- Keeps components focused on UI, not animation mechanics
Using the hook in a component:
function ProgressCircle({ percentage }) {
const [progress, setProgress] = useState(0);
useAnimationFrame((time, delta) => {
setProgress(prev => Math.min(prev + delta * 0.05, percentage));
});
const radius = 45;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (progress / 100) * circumference;
return (
<svg width="100" height="100">
<circle
cx="50" cy="50" r={radius}
fill="none" stroke="#e5e7eb" strokeWidth="8"
/>
<circle
cx="50" cy="50" r={radius}
fill="none" stroke="#3b82f6" strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
);
}
For teams implementing complex interactive interfaces, building custom hooks like this is a core web development skill that improves code organization and maintainability. When animations are encapsulated in reusable hooks, you can easily swap implementations or optimize performance without affecting component logic.
As noted by CSS-Tricks, the key to successful animation hooks lies in proper cleanup and managing the callback dependency correctly.
Practical Animation Examples
Progress Bar Animation
Frame-independent progress animations calculate progress based on elapsed time:
- Track start time when animation begins
- Calculate progress as fraction of elapsed time vs total duration
- Apply easing functions for natural motion
function AnimatedProgressBar({ duration = 2000 }) {
const [progress, setProgress] = useState(0);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
if (!startTimeRef.current) startTimeRef.current = currentTime;
const elapsed = currentTime - startTimeRef.current;
const rawProgress = Math.min(elapsed / duration, 1);
// Ease-out function for smooth deceleration
const easedProgress = 1 - Math.pow(1 - rawProgress, 3);
setProgress(easedProgress);
if (rawProgress < 1) {
requestAnimationFrame(animate);
}
};
const requestId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestId);
}, [duration]);
return (
<div className="progress-bar-container">
<div
className="progress-bar-fill"
style={{ width: `${progress * 100}%` }}
/>
</div>
);
}
Easing transforms:
- Linear:
progress - Ease-in:
progress * progress - Ease-out:
1 - Math.pow(1 - progress, 2) - Ease-in-out:
progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
Circular Loader Animations
Circular loaders use SVG stroke-dashoffset:
function CircularLoader({ size = 60, strokeWidth = 4, duration = 1500 }) {
const [rotation, setRotation] = useState(0);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
if (!startTimeRef.current) startTimeRef.current = currentTime;
const elapsed = currentTime - startTimeRef.current;
const progress = (elapsed % duration) / duration;
setRotation(progress * 360);
requestAnimationFrame(animate);
};
const requestId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestId);
}, [duration]);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
return (
<svg width={size} height={size} style={{ transform: `rotate(${rotation}deg)` }}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#e5e7eb"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#3b82f6"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={circumference * 0.25}
strokeLinecap="round"
style={{ transform: `rotate(${rotation * 2}deg)`, transformOrigin: 'center' }}
/>
</svg>
);
}
These patterns, demonstrated in the OpenReplay Blog, form the foundation for smooth, performant animations in React applications.
Performance Best Practices
Minimizing React Renders
For high-frequency animations, consider whether every frame needs React state updates:
- Some animations work through refs and direct DOM manipulation
- Batching related updates reduces render overhead
- Consider bypassing React entirely for maximum performance
// Direct DOM manipulation - no React render
function DirectAnimation() {
const elementRef = useRef(null);
useEffect(() => {
const animate = () => {
if (elementRef.current) {
elementRef.current.style.transform = `rotate(${Date.now() % 360}deg)`;
}
requestAnimationFrame(animate);
};
const requestId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestId);
}, []);
return <div ref={elementRef}>Spinning</div>;
}
Throttling for Complex Animations
Very complex animations may benefit from throttling:
- Skip some frames and interpolate between visible states
- Maintains perceived smoothness while reducing load
- Particularly important for animations with many elements
Essential Cleanup Patterns
useEffect(() => {
const requestId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestId);
}, []);
Always:
- Cancel pending animation frames on unmount
- Test on lower-powered devices
- Profile performance under CPU load
Our web development services include implementing smooth, performant animations that enhance user experience without compromising site performance. We apply these same patterns when building custom interfaces that require fluid motion. Combined with AI-powered automation solutions, these techniques create modern web experiences that engage users while maintaining exceptional performance across all devices.
Frequently Asked Questions
Why is my animation jittery?
Jittery animations often result from running too much code per frame, stale closures reading old state values, or not using requestAnimationFrame. Check that you're using functional state updates and that your animation callback is lightweight.
How do I stop an animation programmatically?
Store the request ID in a ref and call cancelAnimationFrame(requestId.current) when you need to stop. Use a state flag to prevent further updates after stopping.
Can I use this with React Native?
Yes, requestAnimationFrame works in React Native with Animated API. The same patterns apply, though React Native's Animated API often provides better performance for complex animations. For mobile development, our [mobile app development services](/services/mobile-app-development/) can help you build smooth animations across platforms.
How do I pause and resume animations?
Track animation state (running/paused) in a ref. When paused, skip the requestAnimationFrame call. On resume, call requestAnimationFrame again to continue from the current position.