Skip to main content
React Hooks & Lifecycle: Patterns That Work

React Hooks & Lifecycle: Patterns That Work

Oct 5, 2025

React hooks look simple. useState, useEffect, done. Then you hit race conditions, stale closures, infinite loops, and memory leaks.

Lets fix that.

The useEffect Mental Model

Forget componentDidMount. useEffect is different:

useEffect runs AFTER render, not before. And cleanup runs BEFORE the next effect, not on unmount only.

1. The Dependency Array

// ❌ Runs on EVERY render
useEffect(() => {
  fetchData();
});

// ✅ Runs ONCE on mount
useEffect(() => {
  fetchData();
}, []);

// ✅ Runs when userId changes
useEffect(() => {
  fetchUser(userId);
}, [userId]);

The rule: Include everything the effect uses from component scope.

// ❌ Missing dependency
function Profile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // userId is used but not in deps!
}

// ✅ Correct
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

2. Cleanup: Prevent Memory Leaks

// ❌ Memory leak: subscription never cleaned up
useEffect(() => {
  const subscription = eventBus.subscribe('message', handleMessage);
}, []);

// ✅ Cleanup on unmount
useEffect(() => {
  const subscription = eventBus.subscribe('message', handleMessage);

  return () => {
    subscription.unsubscribe();
  };
}, []);

Common things that need cleanup:

  • Event listeners
  • Subscriptions
  • Timers (setTimeout, setInterval)
  • WebSocket connections
  • AbortController for fetch

3. Race Conditions in Data Fetching

This is the most common useEffect bug:

// ❌ Race condition
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

// ✅ Fixed with cleanup flag
useEffect(() => {
  let cancelled = false;

  fetchUser(userId).then(data => {
    if (!cancelled) {
      setUser(data);
    }
  });

  return () => {
    cancelled = true;
  };
}, [userId]);

// ✅ Even better: AbortController
useEffect(() => {
  const controller = new AbortController();

  fetchUser(userId, { signal: controller.signal })
    .then(setUser)
    .catch(error => {
      if (error.name !== 'AbortError') {
        setError(error);
      }
    });

  return () => controller.abort();
}, [userId]);

4. Stale Closures

// ❌ Stale closure: always logs initial count
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(count); // Always 0!
    }, 1000);

    return () => clearInterval(interval);
  }, []); // count not in deps

  return <button onClick={() => setCount(c => c + 1)}>+</button>;
}

// ✅ Use ref for latest value
function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(countRef.current); // Always current!
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return <button onClick={() => setCount(c => c + 1)}>+</button>;
}

5. Infinite Loops

// ❌ Infinite loop: object recreated every render
useEffect(() => {
  doSomething(options);
}, [options]); // options = { limit: 10 } recreated each render!

// ✅ Memoize the object
const options = useMemo(() => ({ limit: 10 }), []);

useEffect(() => {
  doSomething(options);
}, [options]);

// ✅ Or use primitive values
useEffect(() => {
  doSomething({ limit });
}, [limit]);

6. Custom Hooks: Extract Logic

// Custom hook for data fetching
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetchUser(userId)
      .then(data => {
        if (!cancelled) {
          setUser(data);
          setError(null);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
        }
      })
      .finally(() => {
        if (!cancelled) {
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [userId]);

  return { user, loading, error };
}

// Use it
function Profile({ userId }: { userId: string }) {
  const { user, loading, error } = useUser(userId);

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <UserCard user={user} />;
}

7. useCallback and useMemo

Prevent unnecessary re-renders:

// ❌ New function every render
function Parent() {
  const handleClick = () => console.log('clicked');

  return <Child onClick={handleClick} />;
}

// ✅ Stable function reference
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
}

// useMemo for expensive calculations
function ExpensiveList({ items, filter }: Props) {
  const filteredItems = useMemo(() => {
    return items.filter(item => item.name.includes(filter));
  }, [items, filter]);

  return <List items={filteredItems} />;
}

When to use:

  • useCallback: Functions passed to child components or used in useEffect deps
  • useMemo: Expensive calculations, object/array stability for deps

When NOT to use:

  • Simple calculations
  • Values not used in child props or deps

8. useRef: Not Just for DOM

// DOM reference
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();

// Mutable value that persists across renders
const renderCount = useRef(0);
renderCount.current += 1;

// Previous value
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// Instance variables (like this.x in classes)
function Timer() {
  const intervalRef = useRef<NodeJS.Timeout>();

  const start = () => {
    intervalRef.current = setInterval(() => {
      // ...
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalRef.current);
  };
}

9. useLayoutEffect: Measure Before Paint

// useLayoutEffect for measurements
function Tooltip({ children, targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    // Measure BEFORE browser paints
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({ top: rect.bottom, left: rect.left });
  }, [targetRef]);

  return (
    <div style={{ position: 'absolute', ...position }}>
      {children}
    </div>
  );
}

Use useLayoutEffect when:

  • Measuring DOM elements
  • Preventing visual flicker
  • Synchronous DOM mutations

10. The Hooks Checklist

Before writing useEffect, ask:

  1. Can this be done during render instead?
  2. Is this actually a side effect?
  3. Should this be a custom hook?
  4. Am I handling race conditions?
  5. Am I cleaning up resources?

Common Patterns Summary

| Pattern | Solution | |---------|----------| | Fetch data | useEffect + cleanup flag or AbortController | | Subscribe | useEffect + cleanup | | Debounce | useEffect + setTimeout + cleanup | | Previous value | useRef + useEffect | | Stable callback | useCallback | | Expensive calc | useMemo | | Measure DOM | useLayoutEffect | | Store mutable value | useRef |

Further Reading

Hooks are powerful but tricky. The key is understanding that effects run AFTER render and cleanup runs BEFORE the next effect. Once that clicks, most bugs become obvious.

© 2026 Tawan. All rights reserved.