ESLint plugin for React and Next.js projects with rules to improve code quality and catch common mistakes.
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',
'@laststance/react-next/no-deopt-use-callback': 'error',
'@laststance/react-next/no-deopt-use-memo': '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.
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/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.
❌ 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>
}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 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, function calls, and string concatenations.
❌ 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={classNames('btn', { active: 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 classNames from 'classnames'
function Component({ isActive, theme }) {
// Memoize complex className logic
const buttonClassName = useMemo(
() => classNames('btn', { active: isActive }, theme),
[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>
{/* Memoized complex logic */}
<button className={buttonClassName}>Complex Button</button>
</div>
)
}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