ESLint plugin for React and Next.js projects that includes one rule for my personal use and a rule to prevent infinite re-renders during Vibe Coding.
npm install --save-dev @laststance/react-next-eslint-plugin@latestyarn add --dev @laststance/react-next-eslint-plugin@latestpnpm add --save-dev @laststance/react-next-eslint-plugin@latestimport lastStanceReactNextPlugin from '@laststance/react-next-eslint-plugin'
export default [
{
plugins: {
'@laststance/react-next': lastStanceReactNextPlugin,
},
rules: {
'@laststance/react-next/no-jsx-without-return': 'error',
'@laststance/react-next/all-memo': 'error',
'@laststance/react-next/no-use-reducer': 'error',
'@laststance/react-next/no-set-state-prop-drilling': [
'error',
{ depth: 1 },
],
'@laststance/react-next/no-deopt-use-callback': 'error',
'@laststance/react-next/no-deopt-use-memo': 'error',
'@laststance/react-next/no-direct-use-effect': 'error',
'@laststance/react-next/no-forward-ref': 'error',
'@laststance/react-next/no-context-provider': 'error',
'@laststance/react-next/no-missing-key': 'error',
'@laststance/react-next/no-duplicate-key': 'error',
'@laststance/react-next/no-missing-component-display-name': 'error',
'@laststance/react-next/no-nested-component-definitions': 'error',
'@laststance/react-next/no-missing-button-type': 'error',
'@laststance/react-next/prefer-stable-context-value': 'error',
'@laststance/react-next/no-unstable-classname-prop': 'error',
'@laststance/react-next/prefer-usecallback-might-work': 'error',
'@laststance/react-next/prefer-usecallback-for-memoized-component': 'error',
'@laststance/react-next/prefer-usememo-for-memoized-component': 'error',
'@laststance/react-next/prefer-usememo-might-work': 'error',
},
},
]These rules are provided by the plugin. Enable only those you need. Click on each rule for detailed documentation. Some rules are imported and adapted from https://github.com/jsx-eslint/eslint-plugin-react.
laststance/no-jsx-without-return: Disallow JSX elements not returned or assignedlaststance/all-memo: Enforce wrapping React function components withReact.memolaststance/no-use-reducer: DisallowuseReducerhook in favor of Redux Toolkit to eliminate bugslaststance/no-set-state-prop-drilling: Disallow passinguseStatesetters via props; prefer semantic handlers or state managementlaststance/no-deopt-use-callback: Flag meaninglessuseCallbackusage with intrinsic elements or inline callslaststance/no-deopt-use-memo: Flag meaninglessuseMemousage with intrinsic elements or inline handlerslaststance/no-direct-use-effect: Disallow callinguseEffectdirectly inside React components; extract to custom hookslaststance/no-forward-ref: Prefer passingrefas a prop instead offorwardRef(React 19)laststance/no-context-provider: Prefer rendering<Context>instead of<Context.Provider>(React 19)laststance/no-missing-key: Disallow list items withoutkeylaststance/no-duplicate-key: Disallow duplicatekeyvalues among siblingslaststance/no-missing-component-display-name: RequiredisplayNamefor anonymous memo/forwardRef componentslaststance/no-nested-component-definitions: Disallow defining components inside other componentslaststance/no-missing-button-type: Require explicittypefor button elementslaststance/prefer-stable-context-value: Prefer stableContext.Providervalues (wrap withuseMemo/useCallback)laststance/no-unstable-classname-prop: Avoid unstableclassNameexpressions that change identity every renderlaststance/prefer-usecallback-might-work: Ensure custom components receiveuseCallback-stable function propslaststance/prefer-usecallback-for-memoized-component: Ensure function props sent to memoized components are wrapped inuseCallbacklaststance/prefer-usememo-for-memoized-component: Ensure object/array props to memoized components are wrapped inuseMemolaststance/prefer-usememo-might-work: Ensure custom components receiveuseMemo-stable object/array props
The repository now uses a pnpm workspace (pnpm-workspace.yaml). In addition to the plugin package located at the root, there is a Next.js TODO playground under apps/todo-lint-app that intentionally mixes code which should pass/fail the custom rules.
apps/todo-lint-app: Generated withcreate-next-app, wired to consume the local plugin, and equipped with Vitest snapshot tests that execute ESLint and capture its output.
See docs/demo-playground.md for detailed guidance on when and how to refresh the playground snapshot.
Useful commands:
# Run Vitest snapshot tests inside the demo app
pnpm --filter todo-lint-app test
# Update the stored ESLint snapshot after rule/message changes
pnpm --filter todo-lint-app test -- --update
# Lint only the demo app using the workspace plugin build
pnpm --filter todo-lint-app lintThe published package ships index.d.ts typings so flat-config files can import the plugin with autocomplete. Run pnpm typecheck to ensure the declaration files stay in sync when adding new rules.
This rule prevents JSX elements that are not properly returned or assigned, which typically indicates a missing return statement. It specifically catches standalone JSX expressions and JSX in if/else statements without proper return handling.
❌ Incorrect
function Component() {
;<div>Hello World</div> // Missing return statement
}
function Component() {
if (condition) <div>Hello</div> // Missing return or block wrapping
}
function Component() {
if (condition) {
return <div>Hello</div>
} else <div>Goodbye</div> // Missing return or block wrapping
}✅ Correct
function Component() {
return <div>Hello World</div>
}
function Component() {
if (condition) {
return <div>Hello</div>
}
}
function Component() {
if (condition) {
return <div>Hello</div>
} else {
return <div>Goodbye</div>
}
}This rule enforces that all React function components (PascalCase functions returning JSX) are wrapped with React.memo to prevent unnecessary re-renders and improve performance.
This rule ignores the following files:
- Next.js
layout.tsx(Server Components) - Storybook stories that include
.stories.in the filename
❌ Incorrect
// Function component without memo wrapping
const UserCard = ({ name, email }) => {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
</div>
)
}
function ProductItem({ title, price }) {
return (
<div>
<h4>{title}</h4>
<span>${price}</span>
</div>
)
}✅ Correct
import React, { memo } from 'react'
// Wrapped with memo
const UserCard = memo(({ name, email }) => {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
</div>
)
})
const ProductItem = memo(function ProductItem({ title, price }) {
return (
<div>
<h4>{title}</h4>
<span>${price}</span>
</div>
)
})
// Assignment style also works
function ProductItemBase({ title, price }) {
return (
<div>
{title}: ${price}
</div>
)
}
const ProductItem = memo(ProductItemBase)This rule discourages the use of useReducer hook in favor of Redux Toolkit to eliminate the possibility of introducing bugs through complex state management logic and provide better developer experience.
❌ Incorrect
import { useReducer } from 'react'
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 }
case 'decrement':
return { count: state.count - 1 }
default:
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 })
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</div>
)
}✅ Correct
import { useSelector, useDispatch } from 'react-redux'
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: (state) => {
state.count += 1
},
decrement: (state) => {
state.count -= 1
},
},
})
function Counter() {
const count = useSelector((state) => state.counter.count)
const dispatch = useDispatch()
return (
<div>
<span>{count}</span>
<button onClick={() => dispatch(counterSlice.actions.increment())}>
+
</button>
<button onClick={() => dispatch(counterSlice.actions.decrement())}>
-
</button>
</div>
)
}This rule prevents passing useState setter functions directly through props, which creates tight coupling and can cause unnecessary re-renders due to unstable function identity. Instead, it promotes semantic handlers or proper state management.
❌ Incorrect
import { useState } from 'react'
function Parent() {
const [count, setCount] = useState(0)
// Passing setter directly creates tight coupling
return <Child setCount={setCount} count={count} />
}
function Child({ setCount, count }) {
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
}✅ Correct
import { useState, useCallback } from 'react'
function Parent() {
const [count, setCount] = useState(0)
// Semantic handler with clear intent
const handleIncrement = useCallback(() => {
setCount((c) => c + 1)
}, [])
return <Child onIncrement={handleIncrement} count={count} />
}
function Child({ onIncrement, count }) {
return <button onClick={onIncrement}>Count: {count}</button>
}Options
depth(number, default:0): allows passing a setter through up to N component levels within the same file. Imported components stop depth propagation.
This rule detects meaningless uses of useCallback where the function is passed to intrinsic elements (like div, button) or called inside inline handlers. useCallback should primarily stabilize function props for memoized components to preserve referential equality.
❌ Incorrect
import { useCallback } from 'react'
function Component() {
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
return (
<div>
{/* Meaningless: intrinsic elements don't benefit from useCallback */}
<button onClick={handleClick}>Click me</button>
{/* Meaningless: calling inside inline handler defeats the purpose */}
<button onClick={() => handleClick()}>Click me too</button>
</div>
)
}✅ Correct
import React, { useCallback, memo } from 'react'
const MemoizedButton = memo(function MemoizedButton({ onClick, children }) {
return <button onClick={onClick}>{children}</button>
})
function Component() {
// Meaningful: stabilizes prop for memoized component
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
return (
<div>
<MemoizedButton onClick={handleClick}>Click me</MemoizedButton>
{/* Or just use inline for intrinsic elements */}
<button onClick={() => console.log('clicked')}>Simple click</button>
</div>
)
}This rule discourages calling useEffect directly inside React components so that side effects live in focused custom hooks. Keeping components declarative makes them easier to test and reuse.
❌ Incorrect
import { useEffect } from 'react'
function Dashboard() {
useEffect(() => {
trackPageView('dashboard')
}, [])
return <main>Dashboard</main>
}✅ Correct
import { useEffect } from 'react'
function useDashboardTracking() {
useEffect(() => {
trackPageView('dashboard')
}, [])
}
function Dashboard() {
useDashboardTracking()
return <main>Dashboard</main>
}This rule prevents passing new object/array/function literals to Context.Provider values on each render, which causes unnecessary re-renders of all context consumers. Values should be wrapped with useMemo or useCallback.
❌ Incorrect
import React, { createContext, useState } from 'react'
const UserContext = createContext(null)
function UserProvider({ children }) {
const [user, setUser] = useState(null)
return (
<UserContext.Provider
value={{ user, setUser }} // New object on every render!
>
{children}
</UserContext.Provider>
)
}✅ Correct
import React, { createContext, useState, useMemo } from 'react'
const UserContext = createContext(null)
function UserProvider({ children }) {
const [user, setUser] = useState(null)
// Stable reference prevents unnecessary consumer re-renders
const contextValue = useMemo(
() => ({
user,
setUser,
}),
[user],
)
return (
<UserContext.Provider value={contextValue}>{children}</UserContext.Provider>
)
}This rule prevents unstable className expressions that change identity on every render, which can cause performance issues in memoized components. It flags inline objects, arrays, most function calls, and string concatenations. Calls to common class merge utilities (cn, cva, clsx, classnames) are allowed.
❌ Incorrect
function Component({ isActive, theme }) {
return (
<div>
{/* Object literal creates new reference each render */}
<button className={{ active: isActive, theme }}>Button 1</button>
{/* Array literal creates new reference each render */}
<button className={['btn', isActive && 'active']}>Button 2</button>
{/* Function call executes each render */}
<button className={buildClassName('btn', isActive)}>
Button 3
</button>
{/* String concatenation creates new string each render */}
<button className={'btn ' + theme}>Button 4</button>
</div>
)
}✅ Correct
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
function Component({ isActive, theme }) {
return (
<div>
{/* Static strings are fine */}
<button className="btn primary">Static Button</button>
{/* Template literals with stable references */}
<button className={`btn ${theme}`}>Template Button</button>
{/* Utility helpers are allowed */}
<button className={cn('btn', { active: isActive }, theme)}>
Utility Button
</button>
</div>
)
}In React 19, forwardRef is no longer required for function components. This rule flags forwardRef usage so you can pass ref as a prop instead.
❌ Incorrect
const Button = React.forwardRef((props, ref) => {
return <button ref={ref} />
})✅ Correct
const Button = ({ ref }) => {
return <button ref={ref} />
}In React 19, <Context> can be used directly as a provider. This rule warns on <Context.Provider>.
❌ Incorrect
const App = () => <ThemeContext.Provider value={value} />✅ Correct
const App = () => <ThemeContext value={value} />This rule requires key when rendering lists and discourages fragment shorthand in list items.
❌ Incorrect
items.map((item) => <Item />)
items.map((item) => <>{item}</>)✅ Correct
items.map((item) => <Item key={item.id} />)
items.map((item) => <React.Fragment key={item.id}>{item}</React.Fragment>)This rule requires sibling elements to have unique key values.
❌ Incorrect
return [
<Item key="a" />,
<Item key="a" />,
]✅ Correct
return [
<Item key="a" />,
<Item key="b" />,
]Anonymous components wrapped with memo or forwardRef should have an explicit displayName.
❌ Incorrect
const App = React.memo(() => <div />)✅ Correct
const App = React.memo(function App() {
return <div />
})
App.displayName = 'App'Defining components inside other components recreates them on each render. This rule flags nested component definitions.
❌ Incorrect
function Parent() {
function Child() {
return <div />
}
return <Child />
}✅ Correct
function Child() {
return <div />
}
function Parent() {
return <Child />
}Buttons should have an explicit type attribute to avoid implicit submit behavior.
❌ Incorrect
<button />✅ Correct
<button type="button" />This plugin intentionally does not ship a bundled recommended config. Opt-in the rules that fit your codebase.
Contributions are welcome! Please feel free to submit a Pull Request.
MIT © laststance
