Thanks to visit codestin.com
Credit goes to github.com

Skip to content

laststance/react-next-eslint-plugin

@laststance/react-next-eslint-plugin

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.

ESLint plugin preview

Installation

npm install --save-dev @laststance/react-next-eslint-plugin@latest
yarn add --dev @laststance/react-next-eslint-plugin@latest
pnpm add --save-dev @laststance/react-next-eslint-plugin@latest

Usage

Flat Config (ESLint 9.0+)

import 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',
    },
  },
]

Rules

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.

Monorepo Workspace & Demo App

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 with create-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 lint

TypeScript Support

The 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.

Rule Details

no-jsx-without-return

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>
  }
}

all-memo

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)

no-use-reducer

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>
  )
}

no-set-state-prop-drilling

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.

no-deopt-use-callback

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>
  )
}

no-direct-use-effect

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>
}

prefer-stable-context-value

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>
  )
}

no-unstable-classname-prop

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>
  )
}

no-forward-ref

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} />
}

no-context-provider

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} />

no-missing-key

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>)

no-duplicate-key

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" />,
]

no-missing-component-display-name

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'

no-nested-component-definitions

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 />
}

no-missing-button-type

Buttons should have an explicit type attribute to avoid implicit submit behavior.

❌ Incorrect

<button />

✅ Correct

<button type="button" />

Configuration

This plugin intentionally does not ship a bundled recommended config. Opt-in the rules that fit your codebase.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT © laststance