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
yarn add --dev @laststance/react-next-eslint-plugin
pnpm add --save-dev @laststance/react-next-eslint-plugin
import lastStancePlugin from '@laststance/react-next-eslint-plugin'
export default [
{
plugins: {
laststance: lastStancePlugin,
},
rules: {
// Opt-in per rule
'laststance/no-jsx-without-return': 'error',
'laststance/all-memo': 'warn',
'laststance/no-use-reducer': 'warn',
'laststance/no-set-state-prop-drilling': 'warn',
'laststance/no-deopt-use-callback': 'warn',
'laststance/prefer-stable-context-value': 'warn',
'laststance/no-unstable-classname-prop': 'warn',
},
},
]
These rules are provided by the plugin. Enable only those you need.
laststance/no-jsx-without-return
: Disallow JSX elements not returned or assignedlaststance/all-memo
: Enforce wrapping React function components withReact.memo
laststance/no-use-reducer
: DisallowuseReducer
hook in favor of Redux Toolkit to eliminate bugslaststance/no-set-state-prop-drilling
: Disallow passinguseState
setters via props; prefer semantic handlers or state managementlaststance/no-deopt-use-callback
: Flag meaninglessuseCallback
usage with intrinsic elements or inline callslaststance/prefer-stable-context-value
: Prefer stableContext.Provider
values (wrap withuseMemo
/useCallback
)laststance/no-unstable-classname-prop
: Avoid unstableclassName
expressions that change identity every render
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