Back to Blog
9 min readGuide

React Hooks Best Practices: A Comprehensive Guide

Master React Hooks with proven best practices, common pitfalls to avoid, and practical examples for building maintainable applications

React Hooks Best Practices: A Comprehensive Guide

React Hooks revolutionized how we write React components, but with great power comes the responsibility to use them correctly. After building dozens of applications with Hooks, I've compiled the most important best practices every developer should know.

Understanding the Rules of Hooks

Before diving into best practices, let's review the fundamental rules:

Rule #1: Only Call Hooks at the Top Level

Never call Hooks inside loops, conditions, or nested functions. This ensures Hooks are called in the same order each render.

// ❌ Bad: Conditional hook call
function UserProfile({ userId }) {
  if (userId) {
    const user = useUser(userId); // Wrong!
  }
}

// ✅ Good: Hook called at top level
function UserProfile({ userId }) {
  const user = useUser(userId);

  if (!userId) {
    return <div>No user selected</div>;
  }

  return <div>{user.name}</div>;
}

Rule #2: Only Call Hooks from React Functions

Call Hooks only from React function components or custom Hooks, never from regular JavaScript functions.

// ❌ Bad: Hook in regular function
function calculateTotal() {
  const [total, setTotal] = useState(0); // Wrong!
  return total;
}

// ✅ Good: Hook in custom Hook
function useCalculateTotal(items) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0));
  }, [items]);

  return total;
}

useState Best Practices

1. Use Functional Updates for State Based on Previous State

When new state depends on the previous state, use the functional update form to avoid stale closures.

// ❌ Bad: Direct state reference
function Counter() {
  const [count, setCount] = useState(0);

  const incrementThreeTimes = () => {
    setCount(count + 1); // All three will use the same initial count
    setCount(count + 1);
    setCount(count + 1);
  };
}

// ✅ Good: Functional update
function Counter() {
  const [count, setCount] = useState(0);

  const incrementThreeTimes = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
  };
}

2. Initialize State Lazily for Expensive Computations

If initial state requires expensive computation, pass a function to useState instead of calling it directly.

// ❌ Bad: Expensive function called on every render
function TodoList() {
  const [todos, setTodos] = useState(readFromLocalStorage());
}

// ✅ Good: Lazy initialization
function TodoList() {
  const [todos, setTodos] = useState(() => readFromLocalStorage());
}

3. Group Related State

Keep related state together to avoid synchronization issues.

// ❌ Bad: Separate state that's always updated together
function UserForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');
}

// ✅ Good: Combined state object
function UserForm() {
  const [user, setUser] = useState({
    firstName: '',
    lastName: '',
    email: ''
  });

  const updateField = (field, value) => {
    setUser(prev => ({ ...prev, [field]: value }));
  };
}

useEffect Best Practices

1. Always Specify Dependencies Correctly

Include all values from component scope that are used inside useEffect.

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

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // Missing userId dependency!
}

// ✅ Good: All dependencies specified
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

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

2. Clean Up Side Effects

Always clean up subscriptions, timers, and listeners to prevent memory leaks.

// ❌ Bad: No cleanup
function ChatRoom({ roomId }) {
  useEffect(() => {
    const subscription = subscribeToRoom(roomId);
  }, [roomId]);
}

// ✅ Good: Proper cleanup
function ChatRoom({ roomId }) {
  useEffect(() => {
    const subscription = subscribeToRoom(roomId);

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

3. Separate Effects by Concern

Don't combine unrelated logic in a single useEffect. Split them for better readability and maintenance.

// ❌ Bad: Multiple concerns in one effect
function UserDashboard({ userId }) {
  useEffect(() => {
    fetchUser(userId);
    trackPageView();
    setupWebSocket();
  }, [userId]);
}

// ✅ Good: Separated effects
function UserDashboard({ userId }) {
  useEffect(() => {
    fetchUser(userId);
  }, [userId]);

  useEffect(() => {
    trackPageView();
  }, []);

  useEffect(() => {
    const ws = setupWebSocket();
    return () => ws.close();
  }, []);
}

useCallback and useMemo Best Practices

1. Don't Optimize Prematurely

Only use useCallback and useMemo when you have a performance problem. Measure first!

// ❌ Bad: Unnecessary optimization
function TodoList({ todos }) {
  const sortedTodos = useMemo(() => {
    return [...todos].sort((a, b) => a.id - b.id);
  }, [todos]);
}

// ✅ Good: Optimize when needed (large lists, expensive computations)
function TodoList({ todos }) {
  // For small arrays, sorting is fast enough without memoization
  const sortedTodos = [...todos].sort((a, b) => a.id - b.id);
}

2. Use useCallback for Stable Function References

useCallback is crucial when passing callbacks to optimized child components using React.memo.

// ✅ Good: Stable callback reference
function TodoList({ todos, onToggle }) {
  const handleToggle = useCallback((id) => {
    onToggle(id);
  }, [onToggle]);

  return todos.map(todo => (
    <TodoItem
      key={todo.id}
      todo={todo}
      onToggle={handleToggle}
    />
  ));
}

const TodoItem = React.memo(({ todo, onToggle }) => {
  return (
    <div onClick={() => onToggle(todo.id)}>
      {todo.text}
    </div>
  );
});

Custom Hooks Best Practices

1. Name Custom Hooks with "use" Prefix

Always start custom Hook names with "use" so linting rules can find bugs.

// ✅ Good: Proper naming
function useUserData(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

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

  return { user, loading };
}

2. Extract Reusable Logic

Move complex logic into custom Hooks for reusability and testability.

// ✅ Good: Reusable form logic
function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
  };

  const handleBlur = (name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    const fieldErrors = validate({ ...values });
    setErrors(fieldErrors);
  };

  const handleSubmit = (onSubmit) => (e) => {
    e.preventDefault();
    const formErrors = validate(values);
    setErrors(formErrors);

    if (Object.keys(formErrors).length === 0) {
      onSubmit(values);
    }
  };

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

// Usage
function LoginForm() {
  const { values, errors, handleChange, handleBlur, handleSubmit } = useForm(
    { email: '', password: '' },
    validateLogin
  );

  return (
    <form onSubmit={handleSubmit(login)}>
      <input
        name="email"
        value={values.email}
        onChange={e => handleChange('email', e.target.value)}
        onBlur={() => handleBlur('email')}
      />
      {errors.email && <span>{errors.email}</span>}
    </form>
  );
}

3. Return Objects for Extensibility

Return objects instead of arrays from custom Hooks when you have more than 2 values.

// ❌ Bad: Array return with many values
function useApi(url) {
  // ...
  return [data, loading, error, refetch, cancel];
}

// ✅ Good: Object return for clarity
function useApi(url) {
  // ...
  return { data, loading, error, refetch, cancel };
}

Common Pitfalls to Avoid

1. Infinite Loop with useEffect

// ❌ Bad: Creates infinite loop
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(data => {
      setUsers(data); // Triggers re-render
    });
  }, [users]); // Effect runs again because users changed!
}

// ✅ Good: Correct dependency
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []); // Run once on mount
}

2. Stale Closures

// ❌ Bad: Stale closure
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // Always uses initial count value!
    }, 1000);

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

// ✅ Good: Use functional update
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // Always gets current value
    }, 1000);

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

3. Unnecessary Re-renders

// ❌ Bad: New object/array on every render
function TodoList({ filter }) {
  const config = { showCompleted: true }; // New object every render!
  const emptyArray = []; // New array every render!

  useEffect(() => {
    // Runs on every render because config is always "different"
  }, [config]);
}

// ✅ Good: Stable references
function TodoList({ filter }) {
  const config = useMemo(() => ({ showCompleted: true }), []);
  const emptyArray = useMemo(() => [], []);

  // Or even better: define outside component if it doesn't use props/state
}

Advanced Patterns

1. useReducer for Complex State Logic

When state logic involves multiple sub-values or the next state depends on the previous one, useReducer is clearer than useState.

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });

  function cartReducer(state, action) {
    switch (action.type) {
      case 'ADD_ITEM':
        const newItems = [...state.items, action.item];
        return {
          items: newItems,
          total: calculateTotal(newItems)
        };
      case 'REMOVE_ITEM':
        const filtered = state.items.filter(item => item.id !== action.id);
        return {
          items: filtered,
          total: calculateTotal(filtered)
        };
      case 'CLEAR':
        return { items: [], total: 0 };
      default:
        return state;
    }
  }

  return (
    <div>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name} - ${item.price}
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}>
            Remove
          </button>
        </div>
      ))}
      <div>Total: ${state.total}</div>
    </div>
  );
}

2. useRef for Non-Reactive Values

Use useRef for values that shouldn't trigger re-renders but need to persist across renders.

function SearchInput() {
  const [query, setQuery] = useState('');
  const timeoutRef = useRef(null);

  const handleSearch = (value) => {
    setQuery(value);

    // Clear previous timeout
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    // Set new timeout
    timeoutRef.current = setTimeout(() => {
      performSearch(value);
    }, 500);
  };

  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  return (
    <input
      value={query}
      onChange={e => handleSearch(e.target.value)}
    />
  );
}

Conclusion

React Hooks are powerful but require discipline and understanding. Follow these best practices:

  1. Respect the Rules of Hooks - Call them at the top level, only in React functions
  2. Manage Dependencies Correctly - Include all values used inside effects
  3. Clean Up Effects - Prevent memory leaks by returning cleanup functions
  4. Optimize Wisely - Don't use useMemo/useCallback prematurely
  5. Extract Custom Hooks - Encapsulate reusable logic
  6. Avoid Common Pitfalls - Watch for infinite loops and stale closures

The key to mastering Hooks is practice and understanding how they work under the hood. Start simple, measure performance, and optimize when needed. Happy coding!

👨‍💻

Jordan Patel

Web Developer & Technology Enthusiast