Try @eslint-react/kit@beta
logoESLint React

Custom Rules Of Props

Validates JSX props. Includes checks for duplicate props, mixing controlled and uncontrolled props, explicit spread props, and direct props access

Overview

This recipe contains four custom rules for validating JSX props:

  1. noDuplicateProps: Reports duplicate props on a JSX element.
  2. noDirectAccessProps: Reports direct member expression access of component props (e.g., props.name) and enforces destructuring assignment instead.
  3. noExplicitSpreadProps: Reports spreading object literals onto a JSX element instead of writing separate props. Includes auto-fix.
  4. noMixingControlledAndUncontrolledProps: Reports mixing a controlled prop and its uncontrolled counterpart on the same element.

noDuplicateProps

Rule

Copy the following into your project (e.g. .config/noDuplicateProps.ts):

.config/noDuplicateProps.ts
import type { RuleFunction } from "@eslint-react/kit";

/** Options for {@link noDuplicateProps}. */
export type noDuplicatePropsOptions = {
  /** Whether to ignore case when checking for duplicate props. */
  ignoreCase?: boolean;
};

/** Disallow duplicate properties in JSX. */
export function noDuplicateProps(options: noDuplicatePropsOptions = {}): RuleFunction {
  const { ignoreCase = false } = options;
  return (context) => ({
    JSXOpeningElement(node) {
      const seen = new Map<string, string>();

      // ─── Check each attribute ──────────────────────
      for (const attr of node.attributes) {
        if (attr.type !== "JSXAttribute") continue;
        if (attr.name.type !== "JSXIdentifier") continue;

        const name = ignoreCase ? attr.name.name.toLowerCase() : attr.name.name;

        // › Report duplicate
        if (seen.has(name)) {
          context.report({
            node: attr,
            message: `Duplicate prop "${attr.name.name}" found.`,
          });
        } else {
          seen.set(name, attr.name.name);
        }
      }
    },
  });
}

Config

eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noDuplicateProps } from "./.config/noDuplicateProps";

export default [
  // ... other configs
  {
    ...eslintReactKit()
      .use(noDuplicateProps, { ignoreCase: true })
      .getConfig(),
    files: ["src/**/*.tsx"],
  },
];

Examples

Duplicate props on a JSX element

// Problem: duplicate props silently discard earlier values.
<div id="a" id="b" />;
// Problem: duplicate props silently discard earlier values.
<div onClick={handleA} onClick={handleB} />;

Unique props or conditional spreads

// Recommended: each prop appears only once.
<div id="a" className="b" />;
// OK: spreading a variable is a common and valid pattern.
<div id="a" {...props} />;

Reports when a prop appears multiple times on a JSX element. Only the last occurrence takes effect, silently discarding earlier values. This is typically a copy-paste error or a merge conflict leftover.

Spread attributes are ignored because overriding them with explicit props is a common and valid pattern.

noDirectAccessProps

Rule

Copy the following into your project (e.g. .config/noDirectAccessProps.ts):

.config/noDirectAccessProps.ts
import type { RuleFunction } from "@eslint-react/kit";
import { merge } from "@eslint-react/kit";

/** Enforce destructuring assignment for component props. */
export function noDirectAccessProps(): RuleFunction {
  return (context, { collect }) => {
    const { query, visitor } = collect.components(context);

    return merge(visitor, {
      "Program:exit"(program) {
        for (const { node } of query.all(program)) {
          const [props] = node.params;
          if (props == null) continue;
          if (props.type !== "Identifier") continue;
          const propName = props.name;
          const propVariable = context.sourceCode.getScope(node).variables.find((v) => v.name === propName);
          const propReferences = propVariable?.references ?? [];
          for (const ref of propReferences) {
            const { parent } = ref.identifier;
            if (parent.type !== "MemberExpression") continue;
            context.report({
              message: "Use destructuring assignment for component props.",
              node: parent,
            });
          }
        }
      },
    });
  };
}

Config

eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noDirectAccessProps } from "./.config/noDirectAccessProps";

export default [
  // ... other configs
  {
    ...eslintReactKit()
      .use(noDirectAccessProps)
      .getConfig(),
    files: ["src/**/*.tsx"],
  },
];

Examples

Accessing props via member expression

// Problem: accessing props via member expression.
function MyComponent(props) {
  // Problem: Use destructuring assignment for component props.
  return <div>Hello, {props.name}!</div>;
}
// Problem: accessing props via member expression.
const MyComponent = (props) => {
  // Problem: Use destructuring assignment for component props.
  return <div>{props.title}</div>;
};

Destructured props

// Recommended: destructure props in the function signature.
function MyComponent({ name }: { name: string }) {
  return <div>Hello, {name}!</div>;
}
// Recommended: destructure props in the function signature.
const MyComponent = ({ title }: { title: string }) => {
  return <div>{title}</div>;
};

Reports when component props are accessed via member expressions (e.g., props.name).

This rule uses the collect.components collector from @eslint-react/kit to find all function component definitions in the file. For each component, it checks if the first parameter (props) is an Identifier, then finds all references to the props variable and reports any that are used as the object of a MemberExpression.

This encourages consistent destructuring patterns which improve code readability and make it easier to track which props a component uses.

noExplicitSpreadProps

Rule

Copy the following into your project (e.g. .config/noExplicitSpreadProps.ts):

.config/noExplicitSpreadProps.ts
import type { RuleFunction } from "@eslint-react/kit";

/** Disallow spreading object literals in JSX. Write each property as a separate prop. */
export function noExplicitSpreadProps(): RuleFunction {
  return (context) => ({
    JSXSpreadAttribute(node) {
      if (node.argument.type === "ObjectExpression") {
        context.report({
          node,
          message: "Don't spread an object literal in JSX. Write each property as a separate prop instead.",
        });
      }
    },
  });
}

Config

eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noExplicitSpreadProps } from "./.config/noExplicitSpreadProps";

export default [
  // ... other configs
  {
    ...eslintReactKit()
      .use(noExplicitSpreadProps)
      .getConfig(),
    files: ["src/**/*.tsx"],
  },
];

Examples

Spreading object literals in JSX

// Problem: spreading an object literal hides props from view.
<MyComponent {...{ foo, bar, baz }} />;
// Problem: spreading an object literal hides props from view.
<input {...{ disabled: true, readOnly: true }} />;

Spreading variables or conditional expressions

// OK: spreading a variable is a common and valid pattern.
<div {...props} />;
// OK: conditional expressions serve legitimate purposes.
<Comp {...(cond ? { a: "b" } : {})} />;

Reports when an object literal is spread directly onto a JSX element. This is unnecessary. Writing each property as a separate JSX attribute improves readability and makes props visible at a glance.

Only plain object literals are flagged. Conditional expressions, variables, and other non-literal spreads serve legitimate purposes and remain untouched.

noMixingControlledAndUncontrolledProps

Rule

Copy the following into your project (e.g. .config/noMixingControlledAndUncontrolledProps.ts):

.config/noMixingControlledAndUncontrolledProps.ts
import type { RuleFunction } from "@eslint-react/kit";

const CONTROLLED_PAIRS: [controlled: string, uncontrolled: string][] = [
  ["value", "defaultValue"],
  ["checked", "defaultChecked"],
];

/** Disallow using controlled and uncontrolled props on the same element. */
export function noMixingControlledAndUncontrolledProps(): RuleFunction {
  return (context) => ({
    JSXOpeningElement(node) {
      const props = new Set<string>();

      for (const attr of node.attributes) {
        if (attr.type === "JSXSpreadAttribute") continue;
        if (attr.name.type === "JSXNamespacedName") continue;
        props.add(attr.name.name);
      }

      for (const [controlled, uncontrolled] of CONTROLLED_PAIRS) {
        if (!props.has(controlled) || !props.has(uncontrolled)) continue;

        const attrNode = node.attributes.find(
          (a) =>
            a.type === "JSXAttribute"
            && a.name.type !== "JSXNamespacedName"
            && a.name.name === uncontrolled,
        )!;

        context.report({
          node: attrNode,
          message:
            `'${controlled}' and '${uncontrolled}' should not be used together. Use either controlled or uncontrolled mode, not both.`,
        });
      }
    },
  });
}

Config

eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noMixingControlledAndUncontrolledProps } from "./.config/noMixingControlledAndUncontrolledProps";

export default [
  // ... other configs
  {
    ...eslintReactKit()
      .use(noMixingControlledAndUncontrolledProps)
      .getConfig(),
    files: ["src/**/*.tsx"],
  },
];

Examples

Mixing controlled and uncontrolled props

// Problem: mixing controlled and uncontrolled props causes confusing bugs.
<input value={name} defaultValue="World" />;
// Problem: mixing controlled and uncontrolled props causes confusing bugs.
<input type="checkbox" checked={isChecked} defaultChecked />;

Using either controlled or uncontrolled mode

// Recommended: controlled mode with explicit handler.
<input value={name} onChange={handleChange} />;
// Recommended: uncontrolled mode with default value.
<input defaultValue="World" />;

Reports when both a controlled prop and its uncontrolled counterpart appear on the same JSX element. Mixing modes is a mistake. React will silently ignore the default* prop and might emit a console warning, leading to confusing bugs.

Only well-known React prop pairs are checked:

ControlledUncontrolled
valuedefaultValue
checkeddefaultChecked

Using All Rules

To use all four rules together:

eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import {
  noDuplicateProps,
  noDirectAccessProps,
  noExplicitSpreadProps,
  noMixingControlledAndUncontrolledProps,
} from "./.config";

export default [
  // ... other configs
  {
    ...eslintReactKit()
      .use(noDuplicateProps)
      .use(noDirectAccessProps)
      .use(noExplicitSpreadProps)
      .use(noMixingControlledAndUncontrolledProps)
      .getConfig(),
    files: ["src/**/*.tsx"],
  },
];

Further Reading

Resources

  • AST Explorer - A tool for exploring the abstract syntax tree (AST) of JavaScript code, which is essential for writing custom rules.
  • ESLint Developer Guide - Official ESLint documentation for creating custom rules.
  • Using the TypeScript Compiler API - TypeScript compiler API documentation for working with type information in custom rules.

See Also

  • custom-rules-of-state
    Custom rules for validating state usage. Prefer the updater function form in useState setters.

On this page