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.
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:
- Only call hooks at the top level - Never inside loops, conditions, or nested functions
- Only call hooks from React functions - Components or custom hooks only
- Use the ESLint plugin -
eslint-plugin-react-hookscatches 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.