logoESLint React
Recipes

custom-rules-of-props

Validates 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