{ ILoveJS }

usePrevious hook

typescript

A generic React hook that stores and returns the value from the previous render cycle, useful for comparing state changes and triggering animations.

reacthooksstate

Code

typescript
import { useRef, useEffect, useState, useCallback } from 'react';

// Generic usePrevious hook with TypeScript support
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);
  
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
}

// Optional: Hook that also tracks if value changed
function usePreviousWithChange<T>(value: T): {
  previous: T | undefined;
  hasChanged: boolean;
} {
  const previous = usePrevious(value);
  const hasChanged = previous !== undefined && previous !== value;
  
  return { previous, hasChanged };
}

// Animated Counter Component demonstrating usePrevious
interface AnimatedDiffProps {
  value: number;
  duration?: number;
}

function AnimatedDiff({ value, duration = 300 }: AnimatedDiffProps): JSX.Element {
  const previousValue = usePrevious(value);
  const [isAnimating, setIsAnimating] = useState(false);
  
  const diff = previousValue !== undefined ? value - previousValue : 0;
  const diffSign = diff > 0 ? '+' : '';
  const diffColor = diff > 0 ? '#22c55e' : diff < 0 ? '#ef4444' : '#6b7280';
  
  useEffect(() => {
    if (diff !== 0) {
      setIsAnimating(true);
      const timer = setTimeout(() => setIsAnimating(false), duration);
      return () => clearTimeout(timer);
    }
  }, [value, diff, duration]);
  
  const containerStyle: React.CSSProperties = {
    display: 'flex',
    alignItems: 'center',
    gap: '16px',
    fontFamily: 'system-ui, sans-serif',
  };
  
  const valueStyle: React.CSSProperties = {
    fontSize: '48px',
    fontWeight: 'bold',
    color: '#1f2937',
    transition: `transform ${duration}ms ease-out`,
    transform: isAnimating ? 'scale(1.1)' : 'scale(1)',
  };
  
  const diffStyle: React.CSSProperties = {
    fontSize: '24px',
    fontWeight: '600',
    color: diffColor,
    opacity: isAnimating ? 1 : 0,
    transform: isAnimating ? 'translateY(0)' : 'translateY(-10px)',
    transition: `all ${duration}ms ease-out`,
  };
  
  const previousStyle: React.CSSProperties = {
    fontSize: '14px',
    color: '#6b7280',
  };
  
  return (
    <div style={containerStyle}>
      <span style={valueStyle}>{value}</span>
      {diff !== 0 && (
        <span style={diffStyle}>
          {diffSign}{diff}
        </span>
      )}
      <span style={previousStyle}>
        Previous: {previousValue ?? 'N/A'}
      </span>
    </div>
  );
}

// Demo App Component
function CounterDemo(): JSX.Element {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const addRandom = useCallback(() => {
    const random = Math.floor(Math.random() * 10) + 1;
    setCount(c => c + random);
  }, []);
  const reset = useCallback(() => setCount(0), []);
  
  const buttonStyle: React.CSSProperties = {
    padding: '8px 16px',
    fontSize: '16px',
    borderRadius: '6px',
    border: '1px solid #d1d5db',
    backgroundColor: '#ffffff',
    cursor: 'pointer',
    transition: 'background-color 150ms',
  };
  
  const containerStyle: React.CSSProperties = {
    padding: '32px',
    maxWidth: '400px',
    margin: '0 auto',
  };
  
  const buttonContainerStyle: React.CSSProperties = {
    display: 'flex',
    gap: '8px',
    marginTop: '24px',
    flexWrap: 'wrap',
  };
  
  return (
    <div style={containerStyle}>
      <h2 style={{ marginBottom: '24px', color: '#1f2937' }}>
        usePrevious Demo
      </h2>
      
      <AnimatedDiff value={count} duration={400} />
      
      <div style={buttonContainerStyle}>
        <button style={buttonStyle} onClick={decrement}>-1</button>
        <button style={buttonStyle} onClick={increment}>+1</button>
        <button style={buttonStyle} onClick={addRandom}>+Random</button>
        <button style={buttonStyle} onClick={reset}>Reset</button>
      </div>
    </div>
  );
}

export { usePrevious, usePreviousWithChange, AnimatedDiff, CounterDemo };

How It Works

The usePrevious hook leverages React's useRef and useEffect to maintain a reference to the previous render's value. The key insight is timing: useEffect runs after the render is committed to the DOM, so when we read ref.current during render, it still holds the old value. Only after the component finishes rendering does useEffect update the ref with the new value, setting it up for the next render cycle.

The hook is generic () to work with any value type—numbers, strings, objects, or complex state. On the first render, it returns undefined since there's no previous value yet. This is intentional and type-safe; consumers should handle this case appropriately. The usePreviousWithChange variant adds a convenience boolean to detect when values actually change, useful for triggering side effects.

The AnimatedDiff component demonstrates a practical use case: showing the difference between current and previous values with CSS transitions. It calculates the diff, determines the appropriate color (green for increase, red for decrease), and triggers an animation state. The animation timing is managed with useEffect cleanup to prevent memory leaks if the component unmounts mid-animation.

One important edge case: usePrevious compares by reference, not deep equality. If you pass objects or arrays, the hook will see them as "changed" on every render unless you memoize them. For complex state, consider combining with useMemo or a deep comparison library. Also note that the ref update happens asynchronously, so you cannot rely on it within the same render cycle where the value changes.

Use this pattern when you need to compare current and previous values for animations, undo functionality, change detection, or analytics tracking. Avoid it when you actually need synchronous access to the previous value during state updates—in those cases, consider using a reducer pattern or storing history in state directly.