Skip to main content
Back to Articles

React Hooks: Master useEffect, useCallback & Custom Hooks (Complete Guide)

Master React hooks, eliminate memory leaks, optimize performance 100x, and build reusable hook libraries.

March 7, 202610 min readBy Mathematicon

React Hooks: Master useEffect, useCallback & Custom Hooks (Complete Guide)

The Problem You're Solving

Your React component re-renders 100 times per second. It should render once.

// āŒ Bad: Runs expensive calculations on every render (0.5s each time)
function Dashboard() {
  const expensiveData = processLargeDataset();  // Runs 100x!
  return <div>{expensiveData}</div>;
}

// āœ… Good: Runs only once with useMemo (0.5s total)
function Dashboard() {
  const expensiveData = useMemo(() => processLargeDataset(), []);
  return <div>{expensiveData}</div>;
}

That difference = 50 seconds wasted on every page load vs 0.5 seconds.

Without understanding hooks, you'll write components that freeze the browser. With them, you squeeze 100x more performance from the same code.

React hooks mastery appears in 32% of front-end interviews and directly impacts app responsiveness.

Core Hooks Review

useState: State Management

import { useState } from 'react';

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

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

Key points:

  • count = current state
  • setCount = function to update state
  • useState(0) = initial value is 0
  • Updates trigger re-render

useEffect: Side Effects

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    // Fetch when component mounts OR userId changes
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);  // Dependency array

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}

Key points:

  • Runs after render completes
  • Dependency array controls when it runs
  • Missing array = runs every render (āš ļø infinite loops)
  • Empty array [] = runs once (on mount)
  • Array with values = runs when values change

The Dependency Array: Critical Concept

Problem: Infinite Loops

// āŒ WRONG - No dependency array
useEffect(() => {
  setCount(count + 1);  // Runs after render
});  // Runs every render → infinite loop!

// āœ… CORRECT - Empty array (runs once)
useEffect(() => {
  console.log('Component mounted');
}, []);

// āœ… CORRECT - Specific dependencies
useEffect(() => {
  console.log('Count changed to:', count);
}, [count]);  // Only runs when count changes

Missing Dependencies (Memory Leaks)

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [interval, setInterval] = useState(null);

  // āŒ WRONG - interval not in dependency array
  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
    setInterval(id);
  }, []);  // Should include interval? No, setInterval is external

  // āœ… CORRECT - Proper cleanup
  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(id);  // Cleanup function
  }, []);
}

Rule: Include every external value referenced inside useEffect.

Cleanup Functions (Preventing Memory Leaks)

function ChatConnection({ chatId }) {
  useEffect(() => {
    // Setup: Connect to chat
    const socket = new WebSocket(`wss://chat.example.com/${chatId}`);

    socket.onmessage = (event) => {
      console.log('Message:', event.data);
    };

    // Cleanup: Disconnect when component unmounts or chatId changes
    return () => {
      socket.close();
    };
  }, [chatId]);

  return <div id="messages"></div>;
}

Without cleanup: Old socket connections stay open (memory leak). With cleanup: Connections closed before next effect runs.

useCallback: Memoize Functions

Problem: Unnecessary Renders

function SearchUsers() {
  const [results, setResults] = useState([]);

  // āŒ WRONG - New function every render
  const handleSearch = (query) => {
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setResults);
  };

  return (
    <>
      <SearchInput onSearch={handleSearch} />  {/* New function = re-render */}
      <ResultsList results={results} />
    </>
  );
}
// āœ… CORRECT - Same function unless query changes
import { useCallback } from 'react';

function SearchUsers() {
  const [results, setResults] = useState([]);
  const [query, setQuery] = useState('');

  const handleSearch = useCallback((q) => {
    setQuery(q);
    fetch(`/api/search?q=${q}`)
      .then(res => res.json())
      .then(setResults);
  }, []);  // Empty array = same function always

  return (
    <>
      <SearchInput onSearch={handleSearch} />
      <ResultsList results={results} />
    </>
  );
}

When useCallback Matters

// āœ… GOOD - Function passed to child
<ChildComponent onClick={useCallback(handleClick, [deps])} />

// āŒ BAD - Function used locally (no benefit)
const handleClick = useCallback(() => {}, []);
console.log(handleClick);  // Memoizing has minimal benefit here

Rule: Use useCallback when function is a dependency or prop to optimized child.

useMemo: Memoize Expensive Calculations

import { useMemo } from 'react';

function DataAnalysis({ data }) {
  // āŒ SLOW - Recalculates every render (5 seconds)
  const statistics = calculateStatistics(data);

  // āœ… FAST - Calculates only when data changes
  const statistics = useMemo(
    () => calculateStatistics(data),
    [data]
  );

  return <StatsDisplay stats={statistics} />;
}

Real Example: Complex Filtering

import { useMemo, useState } from 'react';

function ProductList() {
  const [products, setProducts] = useState([...]);
  const [filter, setFilter] = useState('');

  // āŒ SLOW - Filters every render (even when filter unchanged)
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(filter.toLowerCase())
  );

  // āœ… FAST - Filters only when products or filter changes
  const filtered = useMemo(
    () => products.filter(p =>
      p.name.toLowerCase().includes(filter.toLowerCase())
    ),
    [products, filter]
  );

  return (
    <>
      <input
        value={filter}
        onChange={e => setFilter(e.target.value)}
      />
      <ProductTable products={filtered} />
    </>
  );
}

Custom Hooks: Reusable Logic

Problem: Code Duplication

// āŒ Same logic repeated in multiple components
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(1)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  return <div>{loading ? 'Loading...' : user?.name}</div>;
}

function UserSettings() {
  // āŒ Same logic again!
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(2)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  return <div>{loading ? 'Loading...' : user?.email}</div>;
}

Solution: Custom Hook

// āœ… Extract to custom hook
function useFetchUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  return { user, loading, error };
}

// āœ… Use in multiple components
function UserProfile() {
  const { user, loading } = useFetchUser(1);
  return <div>{loading ? 'Loading...' : user?.name}</div>;
}

function UserSettings() {
  const { user, loading } = useFetchUser(2);
  return <div>{loading ? 'Loading...' : user?.email}</div>;
}

Advanced Custom Hook: useAsync

function useAsync(asyncFunction, immediate = true) {
  const [status, setStatus] = useState('idle');
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback(async () => {
    setStatus('pending');
    try {
      const res = await asyncFunction();
      setResult(res);
      setStatus('success');
    } catch (err) {
      setError(err);
      setStatus('error');
    }
  }, [asyncFunction]);

  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

  return { status, result, error, execute };
}

// Usage
function UserList() {
  const { status, result: users } = useAsync(
    () => fetch('/api/users').then(r => r.json())
  );

  return (
    <div>
      {status === 'pending' && <p>Loading...</p>}
      {status === 'success' && users?.map(u => <p key={u.id}>{u.name}</p>)}
      {status === 'error' && <p>Failed to load</p>}
    </div>
  );
}

Common Mistakes & Pitfalls

āŒ Mistake 1: Stale Closures

// WRONG - Function captures old value
function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      console.log(count);  // Always logs 0!
    }, 1000);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={handleClick}>Log after 1s</button>
    </div>
  );
}

// CORRECT - Use functional update
function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      setCount(c => console.log(c));  // Logs current count
    }, 1000);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={handleClick}>Log after 1s</button>
    </div>
  );
}

āŒ Mistake 2: useEffect Creating Infinite Loops

// WRONG - No dependency array
useEffect(() => {
  const data = fetchData();
  setData(data);
});  // Runs every render → infinite loop

// CORRECT - Add dependency array
useEffect(() => {
  const data = fetchData();
  setData(data);
}, []);  // Runs once

// CORRECT - If fetch depends on param
useEffect(() => {
  const data = fetchData(param);
  setData(data);
}, [param]);

āŒ Mistake 3: Missing Cleanup Functions

// WRONG - Memory leak
useEffect(() => {
  const id = setInterval(() => {
    console.log('Tick');
  }, 1000);
}, []);  // No cleanup

// CORRECT - Cleanup
useEffect(() => {
  const id = setInterval(() => {
    console.log('Tick');
  }, 1000);

  return () => clearInterval(id);  // Cleanup
}, []);

Performance Optimization Checklist

// 1. Memoize expensive calculations
const expensiveResult = useMemo(() => {
  return complexCalculation(data);
}, [data]);

// 2. Memoize callbacks passed to children
const memoizedCallback = useCallback(() => {
  doSomething();
}, [dependencies]);

// 3. Use React.memo for pure components
const PureComponent = React.memo(function Component(props) {
  return <div>{props.value}</div>;
});

// 4. Lazy load components
const HeavyComponent = lazy(() => import('./HeavyComponent'));

// 5. Clean up subscriptions
useEffect(() => {
  const unsubscribe = subscribe();
  return unsubscribe;  // Cleanup
}, []);

FAQ: Common Questions & Hook Mistakes

Q1: Do I need to include all dependencies?

A: Yes. Missing dependencies cause bugs.

// āŒ WRONG - count used but not in dependencies
useEffect(() => {
  console.log(count);
}, []);  // Missing count!

// āœ… CORRECT
useEffect(() => {
  console.log(count);
}, [count]);

Q2: When should I use useMemo vs useCallback?

A: useMemo for values. useCallback for functions.

// useMemo - memoize a value
const expensiveValue = useMemo(() => {
  return expensiveFunction(data);
}, [data]);

// useCallback - memoize a function
const expensiveFunc = useCallback(() => {
  return expensiveFunction(data);
}, [data]);

Q3: Can I use hooks conditionally?

A: No. Always call hooks at top level.

// āŒ WRONG - Conditional hook
if (shouldFetch) {
  useEffect(() => {
    fetchData();
  }, []);
}

// āœ… CORRECT - Conditional logic inside hook
useEffect(() => {
  if (shouldFetch) {
    fetchData();
  }
}, [shouldFetch]);

Q4: Memory leak warning - what causes it?

A: Forgetting to cleanup subscriptions/timers.

// āŒ WARNS - No cleanup
useEffect(() => {
  const id = setInterval(() => {}, 1000);
}, []);

// āœ… FIXED - With cleanup
useEffect(() => {
  const id = setInterval(() => {}, 1000);
  return () => clearInterval(id);
}, []);

Q5: Interview Question: Implement useForm hook.

A: Here's production-ready approach:

function useForm(initialValues, onSubmit) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = useCallback((e) => {
    const { name, value } = e.target;
    setValues(v => ({ ...v, [name]: value }));
  }, []);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(t => ({ ...t, [name]: true }));
  }, []);

  const handleSubmit = useCallback(async (e) => {
    e.preventDefault();
    const newErrors = validate(values);
    if (Object.keys(newErrors).length === 0) {
      await onSubmit(values);
    }
    setErrors(newErrors);
  }, [values, onSubmit]);

  return { values, errors, touched, handleChange, handleBlur, handleSubmit };
}

// Usage
function LoginForm() {
  const form = useForm(
    { email: '', password: '' },
    async (values) => {
      await login(values);
    }
  );

  return (
    <form onSubmit={form.handleSubmit}>
      <input name="email" {...form} />
      <input name="password" type="password" {...form} />
      <button type="submit">Login</button>
    </form>
  );
}

Conclusion

Master hooks and you'll write React apps that are:

  • Fast - useCallback and useMemo eliminate unnecessary renders
  • Clean - Custom hooks eliminate code duplication
  • Correct - Understanding dependencies prevents bugs
  • Scalable - Proper cleanup prevents memory leaks

The dependency array is your biggest source of bugs. Master it, and you master hooks.


Learn More

Share this article

Related Articles

React Hooks Deep Dive: useEffect, useCallback, Custom Hooks (2026) | Mathematicon