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:
noDuplicateProps: Reports duplicate props on a JSX element.noDirectAccessProps: Reports direct member expression access of component props (e.g.,props.name) and enforces destructuring assignment instead.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.
noDuplicateProps
Rule
Copy the following into your project (e.g. .config/noDuplicateProps.ts):
import type { RuleFunction } from "@eslint-react/kit";
/** Disallow duplicate props on JSX elements. */
export function noDuplicateProps(): RuleFunction {
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);
}
},
};
};
}Config
import eslintReactKit from "@eslint-react/kit";
import { noDuplicateProps } from "./.config/noDuplicateProps";
export default [
// ... other configs
{
...eslintReactKit()
.use(noDuplicateProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Examples
Invalid
<div id="a" id="b" />;
// ^^^ Prop `id` is specified more than once. Only the last one will take effect.<div onClick={handleA} onClick={handleB} />;
// ^^^ Prop `onClick` is specified more than once. Only the last one will take effect.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.
noDirectAccessProps
Rule
Copy the following into your project (e.g. .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
import eslintReactKit from "@eslint-react/kit";
import { noDirectAccessProps } from "./.config/noDirectAccessProps";
export default [
// ... other configs
{
...eslintReactKit()
.use(noDirectAccessProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Examples
Invalid
// Member expression access — will be reported
function MyComponent(props) {
return <div>Hello, {props.name}!</div>;
// ^^^^^^^^^ Use destructuring assignment for component props.
}// Member expression access — will be reported
const MyComponent = (props) => {
return <div>{props.title}</div>;
// ^^^^^^^^^^^ Use destructuring assignment for component props.
};Valid
// Destructured props — OK
function MyComponent({ name }: { name: string }) {
return <div>Hello, {name}!</div>;
}// Destructured props — OK
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):
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
import eslintReactKit from "@eslint-react/kit";
import { noExplicitSpreadProps } from "./.config/noExplicitSpreadProps";
export default [
// ... other configs
{
...eslintReactKit()
.use(noExplicitSpreadProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Examples
Invalid
<MyComponent {...{ foo, bar, baz }} />;
// ^^^ Don't spread an object literal in JSX. Write each property as a separate prop instead.<input {...{ disabled: true, readOnly: true }} />;
// ^^^ Don't spread an object literal in JSX. Write each property as a separate prop instead.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
Rule
Copy the following into your project (e.g. .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
import eslintReactKit from "@eslint-react/kit";
import { noMixingControlledAndUncontrolledProps } from "./.config/noMixingControlledAndUncontrolledProps";
export default [
// ... other configs
{
...eslintReactKit()
.use(noMixingControlledAndUncontrolledProps)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Examples
Invalid
<input value={name} defaultValue="World" />;
// ^^^ 'value' and 'defaultValue' should not be used together. Use either controlled or uncontrolled mode, not both.<input type="checkbox" checked={isChecked} defaultChecked />;
// ^^^ 'checked' and 'defaultChecked' should not be used together. Use either controlled or uncontrolled mode, not both.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:
| Controlled | Uncontrolled |
|---|---|
value | defaultValue |
checked | defaultChecked |
Using All Rules
To use all four rules together:
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.