npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 merge

That'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 rules

Manual

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 test

License

MIT

Further Reading