Skip to content

@noctcore/eslint-plugin-react

React architecture and correctness: prop drilling, state colocation, memoized context values, effect safety.

React components that compile, render, and still rot: a props bundle forwarded unchanged through three layers, state and effects written inline in the .tsx, a context value that is a fresh object every render, a setState that lands after the component unmounted. TypeScript and eslint-plugin-react-hooks see none of these, because each is a structural decision rather than a type or hook-order error.

It will yell at you about where code lives as much as what it does. no-state-in-component-body wants state and effects in a colocated <Name>.hooks.ts, max-props-per-component and max-hook-return-surface cap how wide a component or hook is allowed to get, and props-must-be-visual rejects identity props like userId on presentational components. That is a deliberate architecture: containers own data, components render it. It is a bad fit if your codebase keeps hooks inline by convention, or if you are linting a library of generic primitives where wide prop surfaces are the point. Start with the correctness half (context-value-must-be-memoized, require-effect-cancellation, no-effect-derived-state) and switch the structural rules off rather than living with a wall of reports.

Install
npm install --save-dev @noctcore/eslint-plugin-react @typescript-eslint/parser
# or: bun add -D / pnpm add -D @noctcore/eslint-plugin-react @typescript-eslint/parser
eslint.config.js
// eslint.config.js
import tsParser from '@typescript-eslint/parser';
import react from '@noctcore/eslint-plugin-react';
export default [
{
...react.configs.recommended,
files: ['**/*.{ts,tsx}'],
languageOptions: { parser: tsParser },
},
];

The recommended preset enables 14 of 14 rules.

Each rule links to its page, with the options it takes and Incorrect and Correct examples. A good first read is no-prop-drilling.

RuleWhat it reportsPresetFixTypes
component-props-namingA function component's first-param props type must be named <Component>Props. Autofixes by renaming the in-file type declaration and its references when that is safe.errorautofix
context-value-must-be-memoizedA context Provider value must not be an inline object literal — a fresh reference each render re-renders every consumer. Memoize it with useMemo and pass the stable reference.error
max-hook-return-surfaceAn exported use* hook in a hook file may return object literals (top-level or nested one level) of at most max (default 20) members. Wider returns are god-controllers in the making.error
max-hooks-per-fileA hook/query/mutation file (default *.hooks.ts / *.queries.ts / *.mutations.ts) may export at most max (default 4) use* hooks. Split larger files.error
max-props-per-componentA *Props interface/type-literal may declare at most max (default 12) local members. extends clauses are not counted.error
no-effect-derived-stateAn effect whose entire body is setState calls with values derived purely from its dependencies is the "you might not need an effect" anti-pattern — compute the value during render (or with useMemo) instead.error
no-jsx-computationDisallow array methods, arithmetic, and chained logical expressions directly inside JSX {...}. Lift them to a const above the return or into the hook.error
no-jsx-in-hooksA use*-named function must not return JSX — that is a component wearing a hook costume. Rename it to a PascalCase component, or return values instead of elements.error
no-prop-drillingA bundle of maxForwarded+ (default 4) props each forwarded unchanged (name={name}) from the *Props param to the same child component is prop drilling. Compose instead.error
no-state-in-component-bodyState/effect/query hooks must live in the colocated hook file (<Name>.hooks.ts), not in the component .tsx body. The component is a thin shell.error
no-unguarded-web-storageA localStorage / sessionStorage call must sit inside a try block: the access itself throws when storage is disabled, partitioned or full.errorsuggestion
prefer-lazy-state-initA useState initializer that is an expensive call (default JSON.parse / localStorage.getItem / sessionStorage.getItem, or a configured builder) must be wrapped in a function so it runs once on mount, not every render.errorautofix
props-must-be-visualComponent props must be visual. Auth/business-identity and credential prop names (userId, currentUser, *token*, *jwt*, *secret*, apiKey, ...) are disallowed. A live password input is a legitimate visual concern and is intentionally allowed.error
require-effect-cancellationA setState/dispatch that runs after an await or .then() inside a useEffect must be guarded against a unmounted/re-run effect (AbortController, a cancelled flag, or a cleanup return).error

Preset: severity in configs.recommended; off means the preset registers the rule switched off, not listed means it leaves the rule out; both are opt-in, so you turn the rule on yourself. Fix: whether the rule ships an autofix or an editor suggestion. Types: whether the rule needs a type-checked program (parserOptions.projectService).