| name | react-hooks-best-practices |
| description | Provides React patterns for hooks, effects, refs, and component design. Covers escape hatches, anti-patterns, and correct effect usage. Use when reading or writing React components with hooks or effects. |
React Hooks Best Practices
Pair with TypeScript
When working with React, always load both this skill and typescript-best-practices together. TypeScript patterns (type-first development, discriminated unions, Zod validation) apply to React code.
Core Principle: Effects Are Escape Hatches
Effects let you "step outside" React to synchronize with external systems. Most component logic should NOT use Effects. Before writing an Effect, ask: "Is there a way to do this without an Effect?"
When to Use Effects
Effects are for synchronizing with external systems:
- Subscribing to browser APIs (WebSocket, IntersectionObserver, resize)
- Connecting to third-party libraries not written in React
- Setting up/cleaning up event listeners on window/document
- Fetching data on mount (though prefer React Query or framework data fetching)
- Controlling non-React DOM elements (video players, maps, modals)
When NOT to Use Effects
Derived State (Calculate During Render)
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName;
Expensive Calculations (Use useMemo)
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);
Resetting State on Prop Change (Use key)
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
}
function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState('');
}
User Event Handling (Use Event Handlers)
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to cart`);
}
}, [product]);
}
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to cart`);
}
}
Notifying Parent of State Changes
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
}
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
setIsOn(nextIsOn);
onChange(nextIsOn);
}
}
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
}
Chains of Effects
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount < 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
}
}
}
Effect Dependencies
Never Suppress the Linter
useEffect(() => {
const id = setInterval(() => {
setCount(count + increment);
}, 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => clearInterval(id);
}, [increment]);
Use Updater Functions to Remove State Dependencies
useEffect(() => {
connection.on('message', (msg) => {
setMessages([...messages, msg]);
});
}, [messages]);
useEffect(() => {
connection.on('message', (msg) => {
setMessages(msgs => [...msgs, msg]);
});
}, []);
Move Objects/Functions Inside Effects
function ChatRoom({ roomId }) {
const options = { serverUrl, roomId };
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
}
function ChatRoom({ roomId }) {
useEffect(() => {
const options = { serverUrl, roomId };
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
useEffectEvent for Non-Reactive Logic
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
}
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
Wrap Callback Props with useEffectEvent
function ChatRoom({ roomId, onReceiveMessage }) {
useEffect(() => {
connection.on('message', onReceiveMessage);
}, [roomId, onReceiveMessage]);
}
function ChatRoom({ roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
connection.on('message', onMessage);
}, [roomId]);
}
Effect Cleanup
Always Clean Up Subscriptions
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
Data Fetching with Ignore Flag
useEffect(() => {
let ignore = false;
async function fetchData() {
const result = await fetchTodos(userId);
if (!ignore) {
setTodos(result);
}
}
fetchData();
return () => {
ignore = true;
};
}, [userId]);
Development Double-Fire Is Intentional
React remounts components in development to verify cleanup works. If you see effects firing twice, don't try to prevent it with refs:
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
}, []);
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, []);
Refs
Use Refs for Values That Don't Affect Rendering
const timeoutRef = useRef(null);
function handleClick() {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
}, 1000);
}
const countRef = useRef(0);
countRef.current++;
Never Read/Write ref.current During Render
function MyComponent() {
const ref = useRef(0);
ref.current++;
return <div>{ref.current}</div>;
}
function MyComponent() {
const ref = useRef(0);
function handleClick() {
ref.current++;
}
useEffect(() => {
ref.current = someValue;
}, [someValue]);
}
Ref Callbacks for Dynamic Lists
{items.map((item) => {
const ref = useRef(null);
return <li ref={ref} />;
})}
const itemsRef = useRef(new Map());
{items.map((item) => (
<li
key={item.id}
ref={(node) => {
if (node) {
itemsRef.current.set(item.id, node);
} else {
itemsRef.current.delete(item.id);
}
}}
/>
))}
useImperativeHandle for Controlled Exposure
function MyInput({ ref }) {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus() {
realInputRef.current.focus();
},
}));
return <input ref={realInputRef} />;
}
Extended Reference
Detailed material starting at ## Custom Hooks has been moved to reference/extended.md to keep this skill concise. Load that reference when the task requires the moved examples, command catalogs, checklists, platform details, or implementation templates.