React Optimization: The Why and How

I have been working with with React and Next.js for a years, and during this time, I have learned a lot about optimizing React applications for performance. In this blog post, I will try to share some of the key strategies and techniques that I have found to be effective in improving the performance of React applications.

Why do we need optimization?

React's fundamental model is simple: When state changes, React re-renders the component tree to reflect those changes. While this model is beautiful and flexible but can be inefficent.

The Render Cycle

function ParnetComponent() {
  const [count, setCount] = useState(0);
  // Every time parent component re-renders, all child components also re-render
  // even though it doesn't use the changed state. The `count` state is not used in ChildComponent1 and ChildComponent2
  return (
    <div>
      <ChildComponent1 />
      <ChildComponent2 />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Why is this bad?

  • When count changes, ParnetComponent re-renders.
  • React sees that ChildComponent1 and ChildComponent2 are children of ParnetComponent, so it re-renders them too.
  • Even though nothing about ChildComponent1 or ChildComponent2 has changed, they still get re-rendered.
  • if these child components handling a list of 1000s of items, this can lead to significant performance issues.

The Memory Leak Problem

function BadComponent() {
  const [data, setData] = useState([]);

  useEffect(() => {
    const interval = setInterval(() => {
      fetchData().then((newData) => {
        setData(newData);
      });
    }, 1000);
    // PROBLEM: When components unmounts, the interval is still running
    // This can lead to memory leaks and unexpected behavior
  }, []);
  return <div>{data.length}</div>;
}

Why does this leak?

  • Components unmounts(user navigates away)
  • Interval keeps running in the background
  • Every second it tries to update the state of a dead component
  • Event listenrs, subscribers, timers pile up in memory
  • Memory usage increases over time

React.Memo - Preventing Unnecessary Rerenders

React memo is shallow comparison. It asks "Did my props changes"? if no skip re-rendering.

// WITHOUT memo -> re-render every time parent renders

function ExpensiveComponent({ data }) {
  // Some expensive rendering logic
  return <div>{data}</div>;
}

// Here , ExpensiveComponent will re-render every time ParnetComponent re-renders
function ParnetComponent(data) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <ExpensiveComponent data={data} />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

// WITH memo -> re-render only when props change
const ExpensiveComponent = React.memo(ExpensiveComponent);

The hidden pitfall of React.memo

function ParnetComponent() {
  const [count, setCount] = useState(0);
  const data = { value: "Some data" }; // New object on every render

  return (
    <div>
      <ExpensiveComponent data={data} />{" "}
      {/* Always re-renders because 'data' is a new object */}
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Why does this happen?

  • Everytime ParnetComponent renders, a new object is created for data.
  • React.memo does a shallow comparison, sees that data is a new object (different reference), and re-renders ExpensiveComponent.
  • To fix this, make the data stable using useMemo or move it outside the component.

useMemo - Expesnive calculations caching

function ProductList({ product, filter }) {
  // This runs every time component renders

  const filtered = product.filter((p) => p.category === filter);

  return (
    <div>
      {filtered.map((p) => (
        <ProductItem key={p.id} product={p} />
      ))}
    </div>
  );
}

Why this is bad?

  • Parent re-renders for unrelated reasons (like state change)
  • ProductList re-renders
  • Filters 10,000 products, sorts them, slices - wasted works
  • Same input, same output, but we calculated again

The fix with useMemo

function ProductList({ product, filter }) {
  // Memoize the filtered products
  const filtered = useMemo(() => {
    return product.filter((p) => p.category === filter);
  }, [product, filter]); // Recompute only when product or filter changes

  return (
    <div>
      {filtered.map((p) => (
        <ProductItem key={p.id} product={p} />
      ))}
    </div>
  );
}

useCallback - Stable function references

function Parent() {
  const [count, setCount] = useState(0);

  // NEW function created on every render
  const handleClick = () => {
    console.log("Clicked");
  };

  return (
    <div>
      <Child onClick={handleClick} />{" "}
      {/* child re-renders every time because handleClick is a new function, handleClick changes when count changes */}
      <p onClick={() => setCount(count + 1)}>Count: {count}</p>
    </div>
  );
}

const Child = React.memo(({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click Me</button>;
});

Why does this re-render?

  • handleClick is a new function every render
  • React.memo sees onClick prop changed (new function reference)
  • Child re-renders unnecessarily

The fix with useCallback

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("Clicked");
  }, []);

  return (
    <div>
      <Child onClick={handleClick} />
      {/* Child re-renders only when handleClick changes */}
      <p onClick={() => setCount(count + 1)}>Count: {count}</p>
    </div>
  );
}

Why does this re-render?

  • handleClick is memoized with useCallback, so its reference stays the same across renders.
  • New function = re-render, same function = skip re-render.
  • React.memo sees onClick prop unchanged, so Child skips re-rendering.
  • Child only re-renders if handleClick changes (which it won't, because of the empty dependency array).

The Colsure Trap

function Counter() {
  const [count, setCount] = useState(0);

  const logCount = useCallback(() => {
    console.log("Count is:", count); // 'count' is captured from initial render
  }, []); // Empty dependency array

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={logCount}>Log Count</button>
    </div>
  );
}

// GOOD: Fixing the closure trap

const handleClick = useCallback(() => {
  console.log("Count is:", count);
}, [count]); // Now 'count' is a dependency

Proper useEffect Cleanup

// LEAK 1: Event listeners
function BadComponent() {
  useEffect(() => {
    const handleScroll = () => console.log("Scrolling");
    window.addEventListener("scroll", handleScroll);
    // LEAK: Listener never removed!
  }, []);
}

// FIX
function GoodComponent() {
  useEffect(() => {
    const handleScroll = () => console.log("Scrolling");
    window.addEventListener("scroll", handleScroll);

    return () => {
      window.removeEventListener("scroll", handleScroll); // Cleanup!
    };
  }, []);
}

// LEAK 2: Timers
function BadTimer() {
  useEffect(() => {
    const timer = setInterval(() => console.log("Tick"), 1000);
    // LEAK: Timer runs forever!
  }, []);
}

// FIX
function GoodTimer() {
  useEffect(() => {
    const timer = setInterval(() => console.log("Tick"), 1000);
    return () => clearInterval(timer); // Cleanup!
  }, []);
}

// LEAK 3: Subscriptions
function BadSubscription() {
  useEffect(() => {
    const subscription = dataStream.subscribe((data) => {
      console.log(data);
    });
    // LEAK: Subscription active forever!
  }, []);
}

// FIX
function GoodSubscription() {
  useEffect(() => {
    const subscription = dataStream.subscribe((data) => {
      console.log(data);
    });
    return () => subscription.unsubscribe(); // Cleanup!
  }, []);
}

State Management Optimization

// ❌ WORSE: Multiple state updates cause multiple re-renders
function BadForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [age, setAge] = useState(0);

  const handleSubmit = () => {
    setName("John"); // Re-render 1
    setEmail("j@example.com"); // Re-render 2
    setAge(30); // Re-render 3
  };

  return <form>...</form>;
}

// ✅ BETTER: Single state object = single re-render
function GoodForm() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    age: 0,
  });

  const handleSubmit = () => {
    setFormData({
      name: "John",
      email: "j@example.com",
      age: 30,
    }); // Single re-render!
  };

  return <form>...</form>;
}

Derived State - Avoiding Redundant State

// ❌ WORSE: Storing derived data in state
function BadCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0); // Redundant!

  const addItem = (item) => {
    const newItems = [...items, item];
    setItems(newItems);
    setTotal(newItems.reduce((sum, i) => sum + i.price, 0)); // Can get out of sync!
  };

  return <div>Total: ${total}</div>;
}

// ✅ BETTER: Calculate on render
function GoodCart() {
  const [items, setItems] = useState([]);

  const total = items.reduce((sum, i) => sum + i.price, 0); // Always correct!

  const addItem = (item) => {
    setItems([...items, item]);
  };

  return <div>Total: ${total}</div>;
}

// ✅ BEST: Memoize if expensive
function BestCart() {
  const [items, setItems] = useState([]);

  const total = useMemo(
    () => items.reduce((sum, i) => sum + i.price, 0),
    [items],
  );

  return <div>Total: ${total}</div>;
}

Event Handlers

// ❌ WORSE: Inline arrow functions break memo
function BadList({ items }) {
  return (
    <div>
      {items.map((item) => (
        <MemoizedItem
          key={item.id}
          item={item}
          onClick={() => console.log(item.id)} // NEW function every render!
        />
      ))}
    </div>
  );
}

// ✅ BETTER: Single handler with event delegation
function GoodList({ items }) {
  const handleClick = useCallback((id) => {
    console.log(id);
  }, []);

  return (
    <div>
      {items.map((item) => (
        <MemoizedItem
          key={item.id}
          item={item}
          onClick={handleClick}
          id={item.id}
        />
      ))}
    </div>
  );
}

Component Organization Pattern

// Consistent component structure
import React, { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";

/**
 * UserProfile - Displays user information
 * @param {Object} user - User data object
 * @param {Function} onUpdate - Called when user updates profile
 */
function UserProfile({ user, onUpdate }) {
  // 1. State declarations
  const [isEditing, setIsEditing] = useState(false);

  // 2. Computed values (useMemo)
  const displayName = useMemo(() => {
    return `${user.firstName} ${user.lastName}`;
  }, [user.firstName, user.lastName]);

  // 3. Effects
  useEffect(() => {
    // Effect logic
    return () => {
      // Cleanup
    };
  }, []);

  // 4. Event handlers (useCallback)
  const handleEdit = useCallback(() => {
    setIsEditing(true);
  }, []);

  // 5. Render
  return (
    <div>
      {displayName}
      <button onClick={handleEdit}>Edit</button>
    </div>
  );
}

// 6. PropTypes
UserProfile.propTypes = {
  user: PropTypes.shape({
    firstName: PropTypes.string.isRequired,
    lastName: PropTypes.string.isRequired,
  }).isRequired,
  onUpdate: PropTypes.func,
};

// 7. Default props
UserProfile.defaultProps = {
  onUpdate: () => {},
};

export default React.memo(UserProfile);

Custom Hooks Pattern


/**
 * Fetch data from API with loading and error states
 */
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false; // Prevent state updates after unmount

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url);
        const json = await response.json();

        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true; // Cleanup flag
    };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserList() {
  const { data, loading, error } = useApi('/api/users');

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return <div>{data.map(...)}</div>;
}


Final Thoughts

I hope these strategies and techniques help you optimize your React applications for better performance. Remember, optimization is an ongoing process, and it's essential to profile and measure performance regularly to identify bottlenecks specific to your application. Happy coding!