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:
noDuplicateProps: Reports duplicate props on a JSX element.noExplicitSpreadProps: Reports spreading object literals onto a JSX element instead of writing separate props. Includes auto-fix.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
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);
}
},
};
};
}import eslintReactKit from "@eslint-react/kit";
import { noDuplicateProps } from "./eslint.config.rules";
export default [
// ... other configs
{
...eslintReactKit()
.use(noDuplicateProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];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
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.",
});
}
},
});
}import eslintReactKit from "@eslint-react/kit";
import { noExplicitSpreadProps } from "./eslint.config.rules";
export default [
// ... other configs
{
...eslintReactKit()
.use(noExplicitSpreadProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];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
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.`,
});
}
},
});
}import eslintReactKit from "@eslint-react/kit";
import { noMixingControlledAndUncontrolledProps } from "./eslint.config.rules";
export default [
// ... other configs
{
...eslintReactKit()
.use(noMixingControlledAndUncontrolledProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];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:
| Controlled | Uncontrolled |
|---|---|
value | defaultValue |
checked | defaultChecked |
Using All Rules
To use all three rules together:
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.