Published on

Stop the Loop: Preventing Infinite Re-renders in React

Authors
  • avatar
    Name
    Mohit Verma
    Twitter

Hey React developers! 👋 Ever had your browser freeze because your component decided to go into an infinite loop? Let's talk about why this happens and how to fix it. I'll share some real-world examples I've encountered and their solutions.

Common Culprit #1: The useEffect Loop

This is probably the most common cause of infinite re-renders. Here's what it looks like:

// 🚫 This will crash your app
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // This effect updates state, which triggers a re-render,
    // which triggers the effect again... 🔄
    setUser(fetchUser(userId));
  });
}

// ✅ Fixed version
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(data => setUser(data));
  }, [userId]); // Don't forget the dependency array!
}

Common Culprit #2: State Updates in Render

This one's sneaky and often happens with data transformations:

// 🚫 State update during render
function ProductList({ products }) {
  const [filteredProducts, setFilteredProducts] = useState([]);
  
  // This runs every render!
  if (products.length > 0) {
    setFilteredProducts(products.filter(p => p.inStock));
  }

  return <div>{/* render products */}</div>;
}

// ✅ Fixed version - Move to useEffect
function ProductList({ products }) {
  const [filteredProducts, setFilteredProducts] = useState([]);
  
  useEffect(() => {
    setFilteredProducts(products.filter(p => p.inStock));
  }, [products]);

  return <div>{/* render products */}</div>;
}

// ✅ Even better - Just compute during render
function ProductList({ products }) {
  const filteredProducts = useMemo(() => 
    products.filter(p => p.inStock),
    [products]
  );

  return <div>{/* render products */}</div>;
}

Common Culprit #3: Object/Array in Dependencies

This is a tricky one that's bit me more times than I'd like to admit:

// 🚫 New object created every render
function SearchResults({ query }) {
  useEffect(() => {
    fetchResults({ 
      searchTerm: query,
      filters: { category: 'all' } // New object every time!
    });
  }, [{ searchTerm: query, filters: { category: 'all' } }]); // 🔄 Infinite loop!

  return <div>{/* results */}</div>;
}

// ✅ Fixed version
function SearchResults({ query }) {
  const searchConfig = useMemo(() => ({
    searchTerm: query,
    filters: { category: 'all' }
  }), [query]);

  useEffect(() => {
    fetchResults(searchConfig);
  }, [searchConfig]); // Now stable!

  return <div>{/* results */}</div>;
}

Common Culprit #4: Props Drilling Gone Wrong

Sometimes the problem comes from parent components:

// 🚫 Creates new function every render
function ParentComponent() {
  const [count, setCount] = useState(0);

  return (
    <ChildComponent 
      onUpdate={() => setCount(count + 1)} // New function every time!
    />
  );
}

// ✅ Fixed version
function ParentComponent() {
  const [count, setCount] = useState(0);
  
  const handleUpdate = useCallback(() => {
    setCount(c => c + 1);
  }, []); // Stable function reference

  return <ChildComponent onUpdate={handleUpdate} />;
}

Common Culprit #5: Context Updates Gone Wild

Context can cause widespread re-renders if not used carefully:

// 🚫 Every component using this context re-renders on any update
const AppContext = createContext();

function AppProvider({ children }) {
  const [state, setState] = useState({
    user: null,
    theme: 'light',
    notifications: []
  });

  // Everything updates together
  const value = {
    ...state,
    updateUser: (user) => setState({ ...state, user }),
    updateTheme: (theme) => setState({ ...state, theme })
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

// ✅ Split contexts by concern
const UserContext = createContext();
const ThemeContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Real-World Example: Data Fetching Component

Here's a real-world example I recently debugged:

function DataFetchingComponent({ endpoint, transformData }) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  // This was causing issues because transformData was a new function each time
  useEffect(() => {
    let mounted = true;

    async function fetchData() {
      try {
        const response = await fetch(endpoint);
        const rawData = await response.json();
        
        if (mounted) {
          // Only update if component is still mounted
          setData(transformData(rawData));
        }
      } catch (err) {
        if (mounted) {
          setError(err);
        }
      }
    }

    fetchData();

    return () => {
      mounted = false;
    };
  }, [endpoint, transformData]); // transformData was the culprit!

  if (error) return <ErrorDisplay error={error} />;
  if (!data) return <Loading />;
  return <DataDisplay data={data} />;
}

// ✅ Fixed version - Move transform function inside effect
function DataFetchingComponent({ endpoint, getTransformFunction }) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let mounted = true;
    const transformData = getTransformFunction();

    async function fetchData() {
      try {
        const response = await fetch(endpoint);
        const rawData = await response.json();
        
        if (mounted) {
          setData(transformData(rawData));
        }
      } catch (err) {
        if (mounted) {
          setError(err);
        }
      }
    }

    fetchData();

    return () => {
      mounted = false;
    };
  }, [endpoint, getTransformFunction]);

  if (error) return <ErrorDisplay error={error} />;
  if (!data) return <Loading />;
  return <DataDisplay data={data} />;
}

Quick Debugging Tips

When you suspect an infinite loop:

  1. Open React DevTools
  2. Enable "Highlight updates when components render"
  3. Watch for components that keep flashing
  4. Check the component's state and effect dependencies

Prevention Checklist

Before shipping your component, ask yourself:

  • Are all useEffect dependencies properly declared?
  • Are objects/arrays in dependencies memoized?
  • Are you updating state in the right place?
  • Have you tested with React.StrictMode enabled?
  • Are your context providers optimized?

Conclusion

Remember:

  1. Always use dependency arrays in useEffect
  2. Memoize objects and arrays used in dependencies
  3. Don't update state during render
  4. Split context by concern
  5. Use React DevTools to debug

Happy debugging! And remember, we all create infinite loops sometimes - the key is knowing how to spot and fix them! 😊