biome-you-might-not-need-an-effect
v1.2.0
Published
Biome linter plugin to detect React useEffect anti-patterns based on react.dev guidelines
Maintainers
Readme
Most useEffect misuse falls into predictable patterns: deriving state from props, fetching data without cleanup, triggering network requests from state changes, or subscribing to stores manually. This plugin catches those patterns before code review, so you can focus on the things that actually need human eyes.
Quick Start
npm install -D biome-you-might-not-need-an-effect
npx biome-you-might-not-need-an-effect mergeThat's it. The CLI adds the recommended rules to your existing biome.json (or biome.jsonc).
Requires
@biomejs/biome>= 2.0.0
Rules
Recommended preset (7 rules)
Low false-positive rules suitable for any React codebase.
| Rule | Severity | What it catches |
|:-----|:--------:|:----------------|
| no-derived-state | error | Setting state that could be computed during render |
| no-state-reset-effect | warn | Resetting state to a literal on prop change |
| no-network-in-effect | warn | POST/PUT/PATCH/DELETE triggered by state changes |
| no-fetch-without-cleanup | error | Data fetching without a cleanup function (race conditions) |
| no-subscribe-in-effect | warn | Manual subscriptions that should use useSyncExternalStore |
| no-timer-without-cleanup | error | setInterval/setTimeout without cleanup (memory leaks) |
| no-direct-dom-subscription | warn | Direct .onmessage/.onerror assignment instead of addEventListener |
Strict preset (+3 rules)
Additional heuristic rules for greenfield projects. May require occasional suppression.
| Rule | Severity | What it catches |
|:-----|:--------:|:----------------|
| no-effect-chains | warn | Conditional setState in effects creating cascading re-renders |
| no-parent-callback-effect | warn | Calling onChange/onUpdate callbacks inside effects |
| no-observer-in-effect | warn | Browser Observer APIs that could use ref callbacks instead |
Setup
CLI (recommended)
# Add recommended rules to existing config
npx biome-you-might-not-need-an-effect merge
# Add all rules (recommended + strict)
npx biome-you-might-not-need-an-effect merge --strict
# Create a fresh biome.json with plugin rules
npx biome-you-might-not-need-an-effect init
# Remove all plugin rules
npx biome-you-might-not-need-an-effect remove
# Print rule table
npx biome-you-might-not-need-an-effect rulesManual
Add the plugin paths to your biome.json:
{
"plugins": [
// recommended
"./node_modules/biome-you-might-not-need-an-effect/rules/no-derived-state.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-state-reset-effect.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-network-in-effect.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-fetch-without-cleanup.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-subscribe-in-effect.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-timer-without-cleanup.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-direct-dom-subscription.grit",
// strict
"./node_modules/biome-you-might-not-need-an-effect/rules/no-effect-chains.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-parent-callback-effect.grit",
"./node_modules/biome-you-might-not-need-an-effect/rules/no-observer-in-effect.grit"
]
}Rule Details
no-derived-state
If a value can be computed from props or existing state, calculate it during render instead of syncing it with an effect.
// Bad - unnecessary effect and extra re-render
function Component({ items }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items]);
return <div>{count}</div>;
}
// Good - derive during render
function Component({ items }) {
const count = items.length;
return <div>{count}</div>;
}
// Good - expensive computation
function Component({ items }) {
const sorted = useMemo(() => items.sort(compareFn), [items]);
return <List items={sorted} />;
}no-state-reset-effect
Resetting state to a literal (null, "", 0, [], etc.) when a prop changes causes an unnecessary re-render. Use a key prop to reset the whole component instead.
// Bad - extra render cycle on every query change
function SearchResults({ query }) {
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [query]);
}
// Good - React remounts the component when key changes
<SearchResults key={query} query={query} />no-network-in-effect
Network mutations (POST, PUT, PATCH, DELETE) triggered by state changes belong in event handlers, not effects. Effects run after render, which means the request fires at the wrong time and is harder to reason about.
// Bad - POST fires after render, not after user action
useEffect(() => {
if (submitted) {
fetch("/api/submit", { method: "POST", body: data });
}
}, [submitted]);
// Good - request fires when the user clicks
function handleSubmit() {
fetch("/api/submit", { method: "POST", body: data });
}no-fetch-without-cleanup
Data fetching inside useEffect without a cleanup function causes race conditions. If the component re-renders before the fetch completes, the old response can overwrite newer data.
// Bad - race condition if userId changes quickly
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
// Good - stale responses are ignored
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data);
});
return () => { ignore = true; };
}, [userId]);Consider using a data-fetching library like TanStack Query or SWR which handle cleanup, caching, and deduplication automatically.
no-subscribe-in-effect
Manual subscriptions to external stores or DOM events in useEffect are error-prone. React provides useSyncExternalStore for this exact use case, with proper support for concurrent rendering.
// Bad - manual subscription
useEffect(() => {
const unsub = store.subscribe(() => setValue(store.getState()));
return unsub;
}, []);
// Good - built-in hook
const value = useSyncExternalStore(store.subscribe, store.getSnapshot);no-timer-without-cleanup
Timers (setInterval, setTimeout) in useEffect without a cleanup function cause memory leaks and stacking intervals. Always return a cleanup function that clears the timer.
// Bad - interval stacks on every re-render
useEffect(() => {
const id = setInterval(() => {
fetch(url).then(r => r.json()).then(setData);
}, 5000);
}, [url]);
// Good - cleanup prevents stacking
useEffect(() => {
const id = setInterval(() => {
fetch(url).then(r => r.json()).then(setData);
}, 5000);
return () => clearInterval(id);
}, [url]);no-direct-dom-subscription
Direct event handler assignment (.onmessage, .onerror, .onopen, etc.) in useEffect is a subscription pattern. Prefer addEventListener or useSyncExternalStore for better cleanup and concurrent rendering support.
// Bad - direct assignment
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setMessages((prev) => [...prev, e.data]);
return () => ws.close();
}, [url]);
// Good - addEventListener pattern
useEffect(() => {
const ws = new WebSocket(url);
const handler = (e) => setMessages((prev) => [...prev, e.data]);
ws.addEventListener("message", handler);
return () => {
ws.removeEventListener("message", handler);
ws.close();
};
}, [url]);no-effect-chains
Strict only. Effects that conditionally set state based on other state create cascading re-renders (effect chains). Derive the value during render or consolidate logic in event handlers.
// Bad - sets state conditionally, triggers another render
useEffect(() => {
if (step === "loading") {
setStatus("pending");
}
}, [step]);
// Good - derive inline
const status = step === "loading" ? "pending" : currentStatus;no-parent-callback-effect
Strict only. Calling parent callbacks like onChange or onUpdate inside useEffect delays the notification by a render cycle and creates confusing data flow. Call the callback in the event handler that triggers the change.
// Bad - parent notified one render late
function Toggle({ onChange }) {
const [on, setOn] = useState(false);
useEffect(() => {
onChange(on);
}, [on, onChange]);
return <button onClick={() => setOn((v) => !v)}>Toggle</button>;
}
// Good - parent notified immediately
function Toggle({ onChange }) {
const [on, setOn] = useState(false);
function handleClick() {
const next = !on;
setOn(next);
onChange(next);
}
return <button onClick={handleClick}>Toggle</button>;
}no-observer-in-effect
Strict only. Browser Observer APIs (IntersectionObserver, MutationObserver, ResizeObserver) in useEffect can often be replaced with ref callbacks for cleaner lifecycle management, especially with React 19+.
// Bad - Observer in effect
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setVisible(true);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
// Good - ref callback
const callbackRef = useCallback((node) => {
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setVisible(true);
});
observer.observe(node);
return () => observer.disconnect();
}, []);How It Works
Each rule is a GritQL pattern file (.grit) that Biome executes as a plugin. GritQL matches AST structures, so the rules work on the actual code tree, not string matching. The patterns exclude known legitimate uses (fetching, async operations, subscriptions) to minimize false positives.
Suppressing Rules
If a rule flags a legitimate use case, suppress it with a Biome inline comment:
// biome-ignore plugin: fetching config is intentional here
useEffect(() => {
setConfig(window.__APP_CONFIG__);
}, []);Contributing
Contributions are welcome. If you find a false positive or a missed pattern, please open an issue.
git clone https://github.com/wraithyy/biome-you-might-not-need-an-effect
cd biome-you-might-not-need-an-effect
npm install
npm testLicense
Further Reading
- You Might Not Need an Effect - React documentation
- Biome Plugins - Biome plugin system
- GritQL - Pattern language used by Biome plugins
