Effect Patterns & Effect Events
Effects are reactive: they re-run whenever any of their dependencies change. That is usually what you want, but sometimes an effect needs to read a value without reacting to it. Mixing reactive logic (the part that should trigger re-synchronization) with non-reactive logic (the part that should always use the latest value) is the single biggest source of effect bugs. This page shows how to split those two concerns cleanly, and walks through the patterns that cover the vast majority of real effects.
Reactive versus non-reactive logic
Every value you read inside an effect is either reactive or not. Props, state, and values derived from them are reactive — when they change, React must re-run the effect to keep things in sync. By contrast, some logic should run with the latest values but should not, by itself, cause the effect to re-run.
Consider a chat room that connects to a server and shows a notification on connect:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // 🔴 reconnects when theme changes
return <h1>Welcome to {roomId}</h1>;
}
Here roomId is genuinely reactive — changing rooms should reconnect. But theme is not: switching from light to dark should never tear down and rebuild the socket. Because theme is in the dependency array, every theme toggle drops the connection. Removing theme would silence the linter incorrectly and capture a stale value.
Extracting an Effect Event
useEffectEvent lets you pull the non-reactive part into a separate function. The code inside an Effect Event always reads the latest props and state, but referencing the event itself does not make the effect reactive to those values.
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // always latest theme
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ only roomId is reactive
return <h1>Welcome to {roomId}</h1>;
}
Now changing roomId reconnects, while changing theme simply updates what the next notification will use. The linter is satisfied because Effect Events are intentionally omitted from the dependency array.
Effect Events must only be called from inside effects, and never passed to other components or hooks. They are not a general-purpose “latest ref” — reach for them only to read reactive values non-reactively inside an effect.
| Construct | Reads latest value | Triggers re-run | Use for |
|---|---|---|---|
| Dependency in array | Yes | Yes | Values the effect must stay in sync with |
useEffectEvent callback | Yes | No | Non-reactive logic that needs current state |
| Value omitted from array | Stale (captured) | No | Bug — avoid this |
Pattern: subscribe and unsubscribe
The canonical effect connects to an external system and cleans up. Keep the subscription keyed on its reactive inputs and always return a teardown function.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const update = () => setIsOnline(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []); // browser APIs are stable — no deps
return isOnline;
}
Pattern: debounce a reactive value
When an effect should react to a value but not on every keystroke, debounce inside the effect and cancel in cleanup. The dependency is the raw value; the delay is the non-reactive detail.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const id = setTimeout(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
setResults(await res.json());
}, 300);
return () => clearTimeout(id); // cancels stale searches
}, [query]);
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Each new query schedules a fresh timer and clears the previous one, so only the final pause actually fires a request — this also doubles as race-condition protection.
Pattern: sync to the DOM or a non-React API
Some libraries (charts, maps, video players) are imperative. Use an effect to push React state into them and to read back results.
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (isPlaying) {
node.play();
} else {
node.pause();
}
}, [isPlaying]); // syncs imperative player to declarative state
return <video ref={ref} src={src} />;
}
Output:
isPlaying: true -> video.play()
isPlaying: false -> video.pause()
Keep effects small and focused
Each effect should synchronize one thing. If an effect connects a socket and tracks analytics and updates the title, split it. Independent concerns have independent dependencies and cleanup, so combining them forces unrelated work to re-run together and makes teardown order fragile.
// ✅ One concern per effect
useEffect(() => {
document.title = `${unread} unread`;
}, [unread]);
useEffect(() => {
const c = createConnection(roomId);
c.connect();
return () => c.disconnect();
}, [roomId]);
Best Practices
- Put only reactive values your effect must stay in sync with in the dependency array — never delete a dependency to silence the linter.
- Wrap non-reactive logic that still needs the latest state in
useEffectEvent, and call it only from inside the effect. - Always return a cleanup function for anything that subscribes, opens a connection, or schedules a timer.
- Debounce and abort inside the effect so cleanup cancels stale work automatically.
- Give each effect a single responsibility; split unrelated synchronization into separate effects.
- Before writing an effect, ask whether the work can happen during render or in an event handler instead.