React Hooks revolutionized how we write React components, but mastering them requires understanding best practices that ensure clean, performant, and maintainable code. In this comprehensive guide, I'll share the essential React Hooks best practices I've learned through 3+ years of full-stack development.

1. useState Best Practices

The useState hook is fundamental, but using it effectively requires following specific patterns:

Keep State Minimal and Flat

// ❌ Avoid nested state objects
const [user, setUser] = useState({
  profile: { name: '', email: '' },
  preferences: { theme: 'light' }
});

// ✅ Use separate state variables
const [userProfile, setUserProfile] = useState({ name: '', email: '' });
const [userPreferences, setUserPreferences] = useState({ theme: 'light' });

Use Functional Updates for State Dependencies

// ❌ Direct state reference
const increment = () => setCount(count + 1);

// ✅ Functional update
const increment = () => setCount(prev => prev + 1);

2. useEffect Mastery

useEffect is powerful but can lead to performance issues and bugs if not used correctly:

Always Include Dependencies

// ✅ Correct dependency array
useEffect(() => {
  fetchUserData(userId);
}, [userId]);

// ✅ Empty dependency for mount-only effects
useEffect(() => {
  const timer = setInterval(() => {
    // Timer logic
  }, 1000);
  return () => clearInterval(timer);
}, []);

Cleanup Side Effects

Always cleanup subscriptions, timers, and event listeners to prevent memory leaks:

useEffect(() => {
  const subscription = api.subscribe(data => {
    setData(data);
  });
  
  return () => subscription.unsubscribe();
}, []);

3. Custom Hooks for Reusability

Custom hooks are the key to reusable logic. Here's how to create effective custom hooks:

useLocalStorage Hook Example

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('Error saving to localStorage:', error);
    }
  };

  return [storedValue, setValue];
}

4. Performance Optimization with useMemo and useCallback

Optimize expensive calculations and prevent unnecessary re-renders:

useMemo for Expensive Calculations

const expensiveValue = useMemo(() => {
  return heavyCalculation(data);
}, [data]);

const filteredItems = useMemo(() => {
  return items.filter(item => item.category === selectedCategory);
}, [items, selectedCategory]);

useCallback for Function References

const handleClick = useCallback((id) => {
  onItemClick(id);
}, [onItemClick]);

// Pass to child components to prevent unnecessary re-renders
<ChildComponent onClick={handleClick} />

5. useContext Best Practices

Context is powerful but can impact performance if overused:

Split Contexts by Concern

// ❌ Single large context
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  // ... many more properties
});

// ✅ Separate contexts
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();

6. Error Handling with Hooks

Implement robust error handling in your hooks:

function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(url);
        if (!response.ok) throw new Error('Failed to fetch');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

7. Testing Hooks

Testing custom hooks ensures reliability:

import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());
  
  act(() => {
    result.current.increment();
  });
  
  expect(result.current.count).toBe(1);
});

Common Pitfalls to Avoid

  • Infinite Re-renders: Missing dependencies in useEffect
  • Stale Closures: Accessing outdated state in callbacks
  • Overusing useMemo/useCallback: Premature optimization
  • Mutating State Directly: Always use setter functions
  • Conditional Hooks: Never call hooks inside conditions or loops

Conclusion

Mastering React Hooks requires understanding not just the API, but the patterns and best practices that lead to maintainable, performant applications. By following these guidelines, you'll write cleaner React code that scales with your application's growth.

Remember, hooks are tools to solve problems, not problems to solve. Use them judiciously and always consider the trade-offs between complexity and functionality.