Using RequestAnimationFrame With React Hooks

Create smooth, performant animations by combining the browser's requestAnimationFrame API with React's hook system

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:

  1. Simple const/let - Reinitialized on every render
  2. useState - Persists across renders, triggers re-render on change
  3. 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.

Ready to Build Smooth Animations?

Our team creates performant, engaging web experiences using modern React patterns and animation techniques.