react1/25/20248 min read

React Hooks Best Practices

Essential patterns and best practices for using React Hooks effectively

reacthooksbest-practicesperformancepatterns
By Zen Frontend

React Hooks Best Practices

React Hooks revolutionized how we write React components. Understanding best practices is crucial for writing maintainable, performant, and bug-free React applications.

Rules of Hooks

1. Only Call Hooks at the Top Level

// ❌ Bad - Conditional hook call
function BadComponent({ shouldUseEffect }) {
  if (shouldUseEffect) {
    useEffect(() => {
      console.log('Effect runs');
    }, []);
  }
  return <div>Bad example</div>;
}

// ✅ Good - Always call hooks at the top level
function GoodComponent({ shouldUseEffect }) {
  useEffect(() => {
    if (shouldUseEffect) {
      console.log('Effect runs');
    }
  }, [shouldUseEffect]);
  
  return <div>Good example</div>;
}

2. Only Call Hooks from React Functions

// ❌ Bad - Calling hook in regular function
function regularFunction() {
  const [state, setState] = useState(0); // Error!
}

// ✅ Good - Only in React components or custom hooks
function MyComponent() {
  const [state, setState] = useState(0);
  return <div>{state}</div>;
}

useState Best Practices

1. Use Functional Updates for State Based on Previous State

// ❌ Bad - Direct state access
function Counter() {
  const [count, setCount] = useState(0);
  
  const increment = () => {
    setCount(count + 1); // May not work correctly with rapid clicks
  };
  
  return <button onClick={increment}>{count}</button>;
}

// ✅ Good - Functional update
function Counter() {
  const [count, setCount] = useState(0);
  
  const increment = () => {
    setCount(prevCount => prevCount + 1); // Always uses latest state
  };
  
  return <button onClick={increment}>{count}</button>;
}

2. Initialize State with Functions for Expensive Calculations

// ❌ Bad - Expensive calculation on every render
function ExpensiveComponent({ items }) {
  const [filteredItems, setFilteredItems] = useState(
    items.filter(item => item.active) // Runs on every render
  );
  
  return <div>{filteredItems.length} items</div>;
}

// ✅ Good - Lazy initialization
function ExpensiveComponent({ items }) {
  const [filteredItems, setFilteredItems] = useState(() =>
    items.filter(item => item.active) // Only runs once
  );
  
  return <div>{filteredItems.length} items</div>;
}

3. Group Related State

// ❌ Bad - Multiple related state variables
function UserForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState(0);
  
  // ... many setters
}

// ✅ Good - Group related state
function UserForm() {
  const [user, setUser] = useState({
    firstName: '',
    lastName: '',
    email: '',
    age: 0
  });
  
  const updateUser = (field, value) => {
    setUser(prev => ({ ...prev, [field]: value }));
  };
  
  return (
    <form>
      <input 
        value={user.firstName}
        onChange={(e) => updateUser('firstName', e.target.value)}
      />
      {/* ... other inputs */}
    </form>
  );
}

useEffect Best Practices

1. Always Include Dependencies

// ❌ Bad - Missing dependencies
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // Missing userId dependency
  
  return <div>{user?.name}</div>;
}

// ✅ Good - Complete dependency array
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]); // Includes all dependencies
  
  return <div>{user?.name}</div>;
}

2. Clean Up Side Effects

// ❌ Bad - No cleanup
function Timer() {
  const [time, setTime] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setTime(prev => prev + 1);
    }, 1000);
    // No cleanup - memory leak!
  }, []);
  
  return <div>{time}</div>;
}

// ✅ Good - Proper cleanup
function Timer() {
  const [time, setTime] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setTime(prev => prev + 1);
    }, 1000);
    
    return () => clearInterval(interval); // Cleanup
  }, []);
  
  return <div>{time}</div>;
}

3. Separate Concerns

// ❌ Bad - Multiple concerns in one effect
function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [notifications, setNotifications] = useState([]);
  
  useEffect(() => {
    // Fetching user
    fetchUser(userId).then(setUser);
    
    // Fetching posts
    fetchPosts(userId).then(setPosts);
    
    // Setting up notifications
    const subscription = subscribeToNotifications(userId, setNotifications);
    
    return () => subscription.unsubscribe();
  }, [userId]);
  
  return <div>Dashboard</div>;
}

// ✅ Good - Separate effects for separate concerns
function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [notifications, setNotifications] = useState([]);
  
  // User data effect
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
  
  // Posts effect
  useEffect(() => {
    fetchPosts(userId).then(setPosts);
  }, [userId]);
  
  // Notifications effect
  useEffect(() => {
    const subscription = subscribeToNotifications(userId, setNotifications);
    return () => subscription.unsubscribe();
  }, [userId]);
  
  return <div>Dashboard</div>;
}

useMemo and useCallback Best Practices

1. Don't Overuse useMemo and useCallback

// ❌ Bad - Unnecessary memoization
function ExpensiveComponent({ items }) {
  const sortedItems = useMemo(() => {
    return items.sort((a, b) => a.name.localeCompare(b.name));
  }, [items]); // Sorting is not that expensive
  
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // Simple function doesn't need memoization
  
  return <div>{sortedItems.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}

// ✅ Good - Only memoize when necessary
function ExpensiveComponent({ items }) {
  const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
  
  const handleClick = () => {
    console.log('clicked');
  };
  
  return <div>{sortedItems.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}

2. Use useMemo for Expensive Calculations

// ✅ Good - Expensive calculation
function DataVisualization({ data }) {
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      normalized: item.value / Math.max(...data.map(d => d.value)),
      category: categorize(item.value)
    }));
  }, [data]);
  
  return <Chart data={processedData} />;
}

3. Use useCallback for Stable References

// ✅ Good - Stable callback reference
function ParentComponent() {
  const [count, setCount] = useState(0);
  
  const handleIncrement = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);
  
  return <ChildComponent onIncrement={handleIncrement} />;
}

const ChildComponent = React.memo(({ onIncrement }) => {
  return <button onClick={onIncrement}>Increment</button>;
});

Custom Hooks Best Practices

1. Start with "use" Prefix

// ✅ Good - Custom hook naming
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);
    }
  };
  
  return [storedValue, setValue];
}

2. Return Consistent API

// ✅ Good - Consistent return pattern
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
  
  return { data, loading, error };
}

3. Handle Edge Cases

// ✅ Good - Robust custom hook
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    if (delay <= 0) {
      setDebouncedValue(value);
      return;
    }
    
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

Performance Best Practices

1. Avoid Creating Objects in Render

// ❌ Bad - New object on every render
function BadComponent({ items }) {
  return (
    <ExpensiveChild 
      config={{ theme: 'dark', size: 'large' }} // New object every render
      items={items}
    />
  );
}

// ✅ Good - Stable object reference
function GoodComponent({ items }) {
  const config = useMemo(() => ({ 
    theme: 'dark', 
    size: 'large' 
  }), []);
  
  return <ExpensiveChild config={config} items={items} />;
}

2. Use React.memo Wisely

// ✅ Good - Memoize expensive components
const ExpensiveChild = React.memo(({ data, onUpdate }) => {
  const processedData = useMemo(() => {
    return data.map(item => expensiveCalculation(item));
  }, [data]);
  
  return <div>{processedData.map(item => <div key={item.id}>{item.result}</div>)}</div>;
});

// ❌ Bad - Memoizing simple components
const SimpleButton = React.memo(({ onClick, children }) => {
  return <button onClick={onClick}>{children}</button>;
}); // Unnecessary memoization

Common Pitfalls to Avoid

1. Stale Closures

// ❌ Bad - Stale closure
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1); // Always uses initial count value
    }, 1000);
    
    return () => clearInterval(interval);
  }, []); // Missing count dependency
  
  return <div>{count}</div>;
}

// ✅ Good - Fresh closure
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(prev => prev + 1); // Always uses latest count
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);
  
  return <div>{count}</div>;
}

2. Infinite Loops

// ❌ Bad - Infinite loop
function BadComponent() {
  const [data, setData] = useState([]);
  
  useEffect(() => {
    fetchData().then(setData);
  }, [data]); // data changes, effect runs, data changes again...
  
  return <div>{data.length} items</div>;
}

// ✅ Good - Proper dependencies
function GoodComponent() {
  const [data, setData] = useState([]);
  
  useEffect(() => {
    fetchData().then(setData);
  }, []); // Only run once
  
  return <div>{data.length} items</div>;
}

Key Concepts

  1. Rules of Hooks: Always follow the two rules
  2. Dependency Arrays: Include all dependencies in useEffect
  3. Cleanup: Always clean up side effects
  4. Performance: Use memoization judiciously
  5. Custom Hooks: Extract reusable logic
  6. Stable References: Avoid creating new objects/functions in render

Common Interview Questions

  • What are the rules of hooks?
  • When should you use useMemo vs useCallback?
  • How do you avoid stale closures in useEffect?
  • What's the difference between useMemo and useCallback?
  • How do you handle cleanup in useEffect?

Related Topics

  • Custom Hooks
  • useEffect Patterns
  • Performance Optimization
  • React.memo
  • useRef and useImperativeHandle