Skip to main content
Back to Blog
ReactHooksJavaScriptTypeScriptFrontendPerformanceBest Practices

React Hooks Best Practices: Write Cleaner, More Performant Components in 2025

Master React hooks with proven best practices for useState, useEffect, useMemo, useCallback, and custom hooks. Learn performance optimization patterns and avoid common pitfalls that slow down your applications.

8 min read

React hooks transformed how we build components, but with great power comes the potential for great performance problems. After building numerous React applications including InsureSignal and Liquidity Hunters, I've compiled the best practices that actually matter for production applications.

The Golden Rules of Hooks

Before diving into specific hooks, remember these foundational rules:

  1. Only call hooks at the top level - Never inside loops, conditions, or nested functions
  2. Only call hooks from React functions - Components or custom hooks only
  3. Use the ESLint plugin - eslint-plugin-react-hooks catches mistakes early
npm install eslint-plugin-react-hooks --save-dev

useState Best Practices

Use Functional Updates for State Depending on Previous State

// Bad - may use stale state
const [count, setCount] = useState(0);
setCount(count + 1);
setCount(count + 1); // Still adds 1, not 2!

// Good - always uses latest state
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Correctly adds 2

Lazy Initialization for Expensive Computations

// Bad - runs on every render
const [data, setData] = useState(expensiveComputation());

// Good - runs only once
const [data, setData] = useState(() => expensiveComputation());

// Real example
const [filters, setFilters] = useState(() => {
  const saved = localStorage.getItem('filters');
  return saved ? JSON.parse(saved) : defaultFilters;
});

Group Related State with Objects

// Verbose - many useState calls
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');

// Better - grouped state
const [formData, setFormData] = useState({
  firstName: '',
  lastName: '',
  email: '',
});

// Update one field
setFormData(prev => ({ ...prev, firstName: 'Justin' }));

But Split Unrelated State

// Bad - unrelated state grouped
const [state, setState] = useState({
  users: [],
  isModalOpen: false,
  selectedTheme: 'dark',
});

// Good - separate concerns
const [users, setUsers] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('dark');

useEffect Best Practices

Always Specify Dependencies

// Bad - missing dependencies
useEffect(() => {
  fetchUser(userId);
}, []); // userId not in deps!

// Good - all dependencies listed
useEffect(() => {
  fetchUser(userId);
}, [userId]);

Cleanup Subscriptions and Timers

useEffect(() => {
  const subscription = api.subscribe(userId, handleUpdate);

  return () => {
    subscription.unsubscribe(); // Cleanup!
  };
}, [userId]);

useEffect(() => {
  const timer = setInterval(checkStatus, 5000);

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

Handle Race Conditions

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

  async function fetchData() {
    const result = await api.getUser(userId);
    if (!cancelled) {
      setUser(result);
    }
  }

  fetchData();

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

// Or use AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });

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

Avoid Object/Array Dependencies

// Bad - new object every render
useEffect(() => {
  doSomething(options);
}, [{ page: 1, limit: 10 }]); // Always triggers!

// Good - primitive dependencies
const page = 1;
const limit = 10;
useEffect(() => {
  doSomething({ page, limit });
}, [page, limit]);

// Or memoize the object
const options = useMemo(() => ({ page, limit }), [page, limit]);
useEffect(() => {
  doSomething(options);
}, [options]);

useMemo and useCallback: When to Use Them

Don't Memoize Everything

// Unnecessary - simple computation
const doubled = useMemo(() => count * 2, [count]);

// Just do this
const doubled = count * 2;

Do Memoize Expensive Operations

// Good use case - expensive computation
const sortedItems = useMemo(() => {
  return items
    .filter(item => item.active)
    .sort((a, b) => b.priority - a.priority)
    .map(item => enrichItem(item));
}, [items]);

// Good use case - stable reference for child component
const config = useMemo(() => ({
  theme: 'dark',
  locale: userLocale,
  features: enabledFeatures,
}), [userLocale, enabledFeatures]);

useCallback for Event Handlers Passed to Children

// Without useCallback - child re-renders unnecessarily
function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log('clicked');
  };

  return <ExpensiveChild onClick={handleClick} />; // New function every render!
}

// With useCallback - stable reference
function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

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

The Real Test: React DevTools Profiler

Don't guess - measure! Use React DevTools Profiler to identify actual performance issues before adding memoization.

Custom Hooks: Extract and Reuse Logic

Data Fetching Hook

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

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

    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(res.statusText);
        return res.json();
      })
      .then(data => {
        if (!cancelled) setData(data);
      })
      .catch(err => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

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

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

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

Local Storage Hook

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;

    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    setStoredValue(prev => {
      const valueToStore = value instanceof Function ? value(prev) : value;
      localStorage.setItem(key, JSON.stringify(valueToStore));
      return valueToStore;
    });
  }, [key]);

  return [storedValue, setValue] as const;
}

// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');

Debounce Hook

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage - search input
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

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

Previous Value Hook

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

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

  return ref.current;
}

// Usage
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <p>
      Current: {count}, Previous: {prevCount}
    </p>
  );
}

useReducer for Complex State

When state logic gets complex, reach for useReducer:

type State = {
  items: Item[];
  loading: boolean;
  error: string | null;
  selectedId: string | null;
};

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: Item[] }
  | { type: 'FETCH_ERROR'; payload: string }
  | { type: 'SELECT_ITEM'; payload: string }
  | { type: 'DELETE_ITEM'; payload: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, items: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'SELECT_ITEM':
      return { ...state, selectedId: action.payload };
    case 'DELETE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
      };
    default:
      return state;
  }
}

function ItemList() {
  const [state, dispatch] = useReducer(reducer, {
    items: [],
    loading: false,
    error: null,
    selectedId: null,
  });

  // Clean, predictable state updates
  dispatch({ type: 'FETCH_START' });
  dispatch({ type: 'SELECT_ITEM', payload: 'item-123' });
}

Performance Patterns

Lift Content Up, Push State Down

// Bad - entire list re-renders on selection change
function ItemList() {
  const [selectedId, setSelectedId] = useState(null);
  const items = useItems();

  return (
    <ul>
      {items.map(item => (
        <Item
          key={item.id}
          item={item}
          isSelected={item.id === selectedId}
          onSelect={setSelectedId}
        />
      ))}
    </ul>
  );
}

// Good - selection state isolated
function ItemList() {
  const items = useItems();

  return (
    <ul>
      {items.map(item => (
        <SelectableItem key={item.id} item={item} />
      ))}
    </ul>
  );
}

function SelectableItem({ item }) {
  const [isSelected, setIsSelected] = useState(false);
  // Only this component re-renders on selection
}

Use Children to Prevent Re-renders

// Bad - everything re-renders when modal state changes
function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <ExpensiveTree />
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </div>
  );
}

// Good - ExpensiveTree passed as children, doesn't re-render
function ModalWrapper({ children }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      {children}
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </>
  );
}

function App() {
  return (
    <ModalWrapper>
      <ExpensiveTree />
    </ModalWrapper>
  );
}

Conclusion

React hooks are powerful but require understanding to use effectively. The patterns above have served me well across production applications with demanding performance requirements.

Remember: measure before optimizing, use custom hooks to share logic, and don't over-engineer. The best code is often the simplest code that meets your requirements.

For more React patterns and techniques, explore my other blog posts or check out real-world implementations on my portfolio.