HomeJournalThis post

Animating between data states with React + requestAnimationFrame

A practical pattern for animating changing data without turning a dashboard into a motion demo.

JP
JP Casabianca
Designer/Engineer · Bogotá

Most data-state animation should be quiet. The point is not to make numbers dance. The point is to help someone understand that a value changed, a row moved, a filter narrowed the set, or a chart shifted from one state to another.

I reach for animation when the transition explains continuity. If a metric updates from 42 to 57, a short count can help the eye register change. If a bar chart re-sorts, motion can preserve object constancy. If a panel swaps from loading to loaded, a fade can make the state change feel less abrupt.

I avoid animation when it hides causality. A slow flourish after every filter change makes an interface feel expensive. A springy number in an operations dashboard can make serious data feel unserious. The best data animation usually ends before the user has time to notice the craft.

Keep the model separate from the frame loop

React is good at describing state. It is not the right place to run a frame-by-frame loop through component state for every pixel. I prefer to keep the animation model small:

  • Capture the previous data state.
  • Capture the next data state.
  • Track elapsed time inside a requestAnimationFrame loop.
  • Interpolate values for the current frame.
  • Commit only the minimal animated value React needs to render.

For a single number, the hook can stay compact.

import { useEffect, useRef, useState } from "react";

function clamp(value: number, min = 0, max = 1) {
  return Math.min(max, Math.max(min, value));
}

function easeOutCubic(t: number) {
  return 1 - Math.pow(1 - t, 3);
}

function lerp(from: number, to: number, t: number) {
  return from + (to - from) * t;
}

export function useAnimatedNumber(value: number, duration = 420) {
  const frameRef = useRef<number | null>(null);
  const previousRef = useRef(value);
  const [displayValue, setDisplayValue] = useState(value);

  useEffect(() => {
    const from = previousRef.current;
    const to = value;
    const start = performance.now();

    if (frameRef.current) cancelAnimationFrame(frameRef.current);

    function tick(now: number) {
      const progress = clamp((now - start) / duration);
      const eased = easeOutCubic(progress);

      setDisplayValue(lerp(from, to, eased));

      if (progress < 1) {
        frameRef.current = requestAnimationFrame(tick);
      } else {
        previousRef.current = to;
        frameRef.current = null;
      }
    }

    frameRef.current = requestAnimationFrame(tick);

    return () => {
      if (frameRef.current) cancelAnimationFrame(frameRef.current);
    };
  }, [value, duration]);

  return displayValue;
}

This is intentionally plain. There is no spring physics, no timeline object, and no dependency on a motion library. For many product charts and counters, that is enough.

Animate meaning, not components

The hook above animates a number, but the useful pattern is broader: animate the property that carries meaning.

For a progress bar, animate the percentage. For a revenue card, animate the displayed total. For a ranked list, animate item positions with transforms after layout changes. For a chart, interpolate the points or bar heights, not the entire SVG as a blob.

The more directly the animated value maps to user meaning, the easier the motion is to defend. "This bar grows because revenue increased" is clear. "This card slides because the UI feels nicer" is weaker.

Interpolation helpers stay boring

Most data animation needs three helpers: clamp, ease, and interpolate.

Clamp keeps progress between zero and one. Easing changes how time feels. Interpolation turns progress into a value between the old and new state. I usually start with ease-out because it gives the user a quick response and a soft landing.

For arrays, interpolate by stable identity, not by index. If rows can be inserted, removed, or sorted, index-based animation will attach the wrong old value to the wrong new item.

type Point = { id: string; value: number };

function interpolatePoints(previous: Point[], next: Point[], t: number) {
  const previousById = new Map(previous.map((point) => [point.id, point.value]));

  return next.map((point) => {
    const from = previousById.get(point.id) ?? point.value;
    return {
      ...point,
      value: lerp(from, point.value, t),
    };
  });
}

That small identity rule prevents a common dashboard bug: a bar appears to morph into a different category because the sorted order changed.

Respect reduced motion

Reduced motion is not an edge case. Some users explicitly ask the system to reduce nonessential motion, and data-heavy products can become tiring if every update moves.

I handle that at the hook boundary. If reduced motion is enabled, return the target value immediately and skip the frame loop.

function prefersReducedMotion() {
  if (typeof window === "undefined") return true;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

For a production hook, subscribe to media-query changes too. The important part is the decision: animation is an enhancement. The final state must be readable without it.

Performance is mostly about restraint

requestAnimationFrame does not make slow rendering fast. It only schedules work before paint. If each frame causes expensive React trees to re-render, the animation will still stutter.

The practical rules are simple:

  • Animate small values, not whole pages.
  • Prefer transforms and opacity for layout movement.
  • Avoid reading layout inside every frame.
  • Cancel old frames when new data arrives.
  • Keep durations short enough that frequent updates do not stack.
  • Do not animate live operational data that changes faster than a person can read.

For SVG charts, I keep paths memoized where possible and only recalculate the geometry that actually changed. For tables, I am careful. Animating every row in a large table is usually not worth the cost. A highlight on changed cells can explain the update with less work.

When I would use a library

Hand-rolled RAF is useful when the animation is small and the rules are clear. I still use motion libraries when I need gesture handling, exit animations, layout projection, spring tuning, or a shared animation language across a product.

The value of writing the small version yourself is understanding the contract. You learn what starts the animation, what cancels it, what value is interpolated, what happens when data updates mid-flight, and what the reduced-motion path does.

That contract matters more than the tool. Data-state animation earns its place when it makes change easier to understand and then gets out of the way.