logoESLint React
Recipes

custom-rules-of-props

Custom rules for validating JSX props. Includes checks for duplicate props, mixing controlled and uncontrolled props, and explicit spread props.

Overview

This recipe contains three custom rules for validating JSX props:

  1. noDuplicateProps: Reports duplicate props on a JSX element.
  2. noExplicitSpreadProps: Reports spreading object literals onto a JSX element instead of writing separate props. Includes auto-fix.
  3. noMixingControlledAndUncontrolledProps: Reports mixing a controlled prop and its uncontrolled counterpart on the same element.

Rule Definitions

Copy the following into your project (e.g. eslint.config.rules.ts):

noDuplicateProps

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

/** Disallow duplicate props on JSX elements. */
export function noDuplicateProps(): RuleDefinition {
  return (context) => {
    function getPropName(
      attribute: { name: { type: string; namespace?: { name: string }; name: string | { name: string } } },
    ): string {
      if (attribute.name.type === "JSXNamespacedName") {
        const ns = attribute.name.namespace as { name: string };
        const local = attribute.name.name as { name: string };
        return `${ns.name}:${local.name}`;
      }
      return attribute.name.name as string;
    }

    return {
      JSXOpeningElement(node) {
        const seen = new Map<string, boolean>();

        for (const attribute of node.attributes) {
          if (attribute.type === "JSXSpreadAttribute") continue;

          const propName = getPropName(attribute);

          if (seen.has(propName)) {
            context.report({
              node: attribute,
              message: `Prop \`${propName}\` is specified more than once. Only the last one will take effect.`,
            });
          }

          seen.set(propName, true);
        }
      },
    };
  };
}
eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noDuplicateProps } from "./eslint.config.rules";

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

Invalid

<div id="a" id="b" />;
<div on:click={handleA} on:click={handleB} />;

Valid

<div id="a" className="b" />;
<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.

noExplicitSpreadProps

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

/** Disallow spreading object literals in JSX. Write each property as a separate prop. */
export function noExplicitSpreadProps(): RuleDefinition {
  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.",
        });
      }
    },
  });
}
eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noExplicitSpreadProps } from "./eslint.config.rules";

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

Invalid

<MyComponent {...{ foo, bar, baz }} />;
<input {...{ disabled: true, readOnly: true }} />;

Valid

<div {...props} />;
<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

eslint.config.rules.ts
import type { RuleDefinition } 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(): RuleDefinition {
  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.`,
        });
      }
    },
  });
}
eslint.config.ts
import eslintReactKit from "@eslint-react/kit";
import { noMixingControlledAndUncontrolledProps } from "./eslint.config.rules";

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

Invalid

<input value={name} defaultValue="World" />;
<input type="checkbox" checked={isChecked} defaultChecked />;

Valid

<input value={name} onChange={handleChange} />;
<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 three rules together:

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

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

Further Reading


See Also

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

On this page