Tangled effect wires being unplugged from a circuit board while a gray-blue cat watches calmly nearby.

eslint-plugin-react-you-might-not-need-an-effect: Your Linter Now Reads the React Docs For You

The Gray Cat
The Gray Cat
0 views

There is a page in the React docs that practically every React developer has been told to read at least once: You Might Not Need an Effect. It catalogues all the ways people reach for useEffect when render logic, an event handler, or a simple key prop would do the job better. The advice is excellent. The problem is that nobody remembers all of it while they are mid-feature at 4pm on a Friday, and nobody wants to be the reviewer who quotes the docs in a pull request comment for the fortieth time.

eslint-plugin-react-you-might-not-need-an-effect solves that gap. It is a static-analysis plugin that encodes the guidance from that docs page into automated lint rules, so your tooling flags the anti-patterns for you. It runs under ESLint and is also compatible with Oxlint, the Rust-based linter. With roughly 390,000 weekly downloads and a stable 1.0.0 release after a long, carefully hardened beta period, it has quietly become the category-defining tool for this particular class of mistakes.

Why Another React Linting Plugin

The official eslint-plugin-react-hooks package already gives you rules-of-hooks and exhaustive-deps. Those rules are essential, but they answer a different question. They make sure the effects you write are correct: the dependency array is complete, the hooks are called in the right order. They never ask whether the effect should exist in the first place.

That is exactly the question this plugin asks. Unnecessary effects are not syntax errors, so no traditional rule catches them. Yet they quietly cost you:

  • Extra render passes. An effect runs after paint, calls setState, and triggers another render. Derived state stored in state instead of computed during render is the classic offender.
  • Bugs and stale data. Effect-synced state can lag, flicker, or drift out of sync with the props it was supposed to mirror.
  • Tangled data flow. Logic that belongs in an event handler ends up scattered across effects keyed on state changes, making the component much harder to follow.

The plugin describes itself as "edge-case obsessed" and "dependency-aware", and that is the whole design challenge. Detecting these patterns naively would flag every legitimate side effect. The long 0.x release history (over ninety versions before 1.0.0) was largely about hardening the heuristics against false positives on real-world code.

The Nine Patterns It Hunts

The plugin ships nine separate rules, each targeting one shape of unnecessary effect described in the React docs. You rarely need to know them by name because the presets enable all of them at once, but it helps to understand the territory:

  • no-derived-state — storing a computed value in state via an effect instead of calculating it during render.
  • no-chain-state-updates — chained state updates where one effect reacts to a state change just to set more state.
  • no-event-handler — using state plus an effect to simulate logic that belongs in an event handler.
  • no-adjust-state-on-prop-change — adjusting some state when a prop changes, via an effect.
  • no-reset-all-state-on-prop-change — clearing all component state on a prop change (the canonical "use a key instead" case).
  • no-pass-live-state-to-parent — pushing child state up to a parent through an effect callback.
  • no-pass-data-to-parent — passing fetched or derived data up to a parent via an effect.
  • no-external-store-subscription — manually subscribing to an external store in an effect instead of using useSyncExternalStore.
  • no-initialize-state — setting initial state inside an effect instead of passing the value to useState.

The recommended preset turns every rule on as a warning. The strict preset turns every rule on as an error.

Getting It Into Your Project

Installation is a single dev dependency with essentially zero runtime footprint. It depends only on globals and peer-depends on eslint >= 8.40.0.

npm install --save-dev eslint-plugin-react-you-might-not-need-an-effect
yarn add -D eslint-plugin-react-you-might-not-need-an-effect

Wiring Up Flat Config

If you are on ESLint 9 with flat config, the recommended setup is to spread one of the preset configs and let it handle the rest:

import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";
import globals from "globals";

export default [
  // every rule as a warning
  reactYouMightNotNeedAnEffect.configs.recommended,
  // or reactYouMightNotNeedAnEffect.configs.strict for errors
  {
    plugins: { reactYouMightNotNeedAnEffect },
    languageOptions: {
      globals: { ...globals.browser },
      parserOptions: { ecmaFeatures: { jsx: true } },
    },
  },
];

For legacy .eslintrc setups, the plugin exposes configs["legacy-recommended"] and configs["legacy-strict"] instead. You can always dial individual rules up or down by name:

{
  rules: {
    "reactYouMightNotNeedAnEffect/no-derived-state": "error",
    "reactYouMightNotNeedAnEffect/no-event-handler": "warn",
  },
}

Running It Under Oxlint

One genuinely notable feature is first-class Oxlint support. Oxlint's JS-plugin compatibility is relatively new, so shipping a plugin that runs under both linters is rare. The configuration lives in your .oxlintrc.json:

{
  "jsPlugins": ["eslint-plugin-react-you-might-not-need-an-effect"],
  "rules": {
    "react-you-might-not-need-an-effect/no-derived-state": "warn"
  }
}

Note the namespace difference: Oxlint uses the hyphenated react-you-might-not-need-an-effect/... prefix, while the flat-config plugin key in the README is the camelCased reactYouMightNotNeedAnEffect/.... Match the form to the linter you are configuring.

Seeing It Catch Real Mistakes

The fastest way to understand the value is to look at the warnings it produces and the fixes it nudges you toward.

Derived State That Should Be Render Logic

This is the single most common offence. Three pieces of state where there should be two, plus an effect to keep the third in sync:

function Form() {
  const [firstName, setFirstName] = useState("Taylor");
  const [lastName, setLastName] = useState("Swift");
  const [fullName, setFullName] = useState("");

  // no-derived-state flags this effect
  useEffect(() => {
    setFullName(firstName + " " + lastName);
  }, [firstName, lastName]);
}

fullName is not independent state. It is a function of two other values, so it belongs in render where it recomputes automatically and never desyncs:

const fullName = firstName + " " + lastName;

The same logic powers no-chain-state-updates. If you have an effect watching round only to set isGameOver when round > 10, the rule pushes you to derive isGameOver = round > 10 during render, or to update both pieces of state together in the event handler that changed round.

Effects Pretending to Be Event Handlers

When you stash a value in state purely so an effect can react to it and fire a side effect, you have usually reinvented an event handler the long way around:

function Form() {
  const [dataToSubmit, setDataToSubmit] = useState();

  // no-event-handler flags this
  useEffect(() => {
    if (dataToSubmit) {
      submitData(dataToSubmit);
    }
  }, [dataToSubmit]);
}

The fix collapses an entire render cycle out of existence. Call submitData(...) directly from the submit handler where the user action actually happens, and delete both the state and the effect.

Going Deeper Than Single Components

The most interesting rules are the ones that reason about how data flows between components, because those are the bugs that are hardest to spot by eye.

The Key Prop You Forgot About

Resetting all of a component's state when a prop changes is a pattern almost everyone writes before they learn the better way:

function List({ items }) {
  const [selection, setSelection] = useState(null);

  // no-reset-all-state-on-prop-change flags this
  useEffect(() => {
    setSelection(null);
  }, [items]);
}

React already has a built-in mechanism for "throw away all state and start fresh": the key prop. Rendering <List key={items} /> from the parent remounts the component cleanly whenever items changes, no effect required. The rule surfaces a suggestion with a documentation link rather than a blanket auto-fix here, precisely because the cleanest fix changes the component's call site, not its body.

State and Data That Belong to the Parent

Two related rules, no-pass-live-state-to-parent and no-pass-data-to-parent, catch effects that exist only to push information upward:

function Child({ onTextChanged }) {
  const [text, setText] = useState();

  // no-pass-live-state-to-parent flags this
  useEffect(() => {
    onTextChanged(text);
  }, [onTextChanged, text]);
}

When a child holds state only to report it back to a parent, the data flow is inverted. The fix is to lift the state into the parent (or return values from a custom hook). The same principle applies to fetched data: if a child fetches and then hands the result up through an effect, it is usually cleaner to fetch in the parent and pass the data down as props.

Subscriptions That React Already Solved

External stores, browser events, and similar subscriptions are a genuine side effect, but useEffect is no longer the right tool for them:

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);

  // no-external-store-subscription flags this
  useEffect(() => {
    window.addEventListener("online", updateState);
    return () => window.removeEventListener("online", updateState);
  }, []);
}

useSyncExternalStore exists specifically for this and handles tearing, server rendering, and concurrent features correctly in a way a hand-rolled effect does not. The rule points you straight at it.

Knowing When to Overrule the Linter

Because "you might not need an effect" is fundamentally a judgement call, treat the output as informed guidance rather than gospel. Some effects are genuinely warranted: imperative DOM work, third-party integrations, analytics, or a network request with no parent to hand the result to. The rules try hard to leave those alone, but when a warning is wrong for your situation, suppress it the standard way:

// eslint-disable-next-line reactYouMightNotNeedAnEffect/no-derived-state
useEffect(() => {
  // a genuinely necessary effect
}, [dep]);

The maintainer recommends pairing this plugin with react-hooks/exhaustive-deps for the effects you keep, and with typescript-eslint/no-floating-promises to sharpen async function detection. Together they cover both halves of the question: this plugin asks whether the effect should exist, and the official hooks rules make sure the ones that survive are correct.

Worth Adding to Your Config

eslint-plugin-react-you-might-not-need-an-effect is a focused, single-purpose tool that does one thing exceptionally well: it turns a well-known but easy-to-forget React docs page into enforced, automated guidance. It does not try to be a full React rule set, and it is better for the restraint. Drop in the recommended preset, let it surface a few warnings on your existing codebase, and you will almost certainly find an effect or two that quietly wanted to be render logic all along. Pair it with the official hooks plugin, and your linter starts catching the kind of mistakes that used to slip through to production.