/**
* Rule: no-prop-drilling-depth
*
* Warns when a prop is passed through multiple intermediate components
* without being used, indicating prop drilling that should be refactored
* to context or state management.
*
* Examples:
* - Bad: Prop passed through 3+ components without use
* - Good: Prop used directly in receiving component
* - Good: Using Context or Zustand instead of drilling
*/
import type { TSESTree } from "@typescript-eslint/utils";
import { createRule, defineRuleMeta } from "../utils/create-rule.js";
type MessageIds = "propDrilling";
type Options = [
{
/** Maximum depth before warning (default: 2) */
maxDepth?: number;
/** Props to ignore (e.g., className, style, children) */
ignoredProps?: string[];
/** Component patterns to skip (regex strings) */
ignoreComponents?: string[];
}
];
/**
* Rule metadata - colocated with implementation for maintainability
*/
export const meta = defineRuleMeta({
id: "no-prop-drilling-depth",
version: "1.0.0",
name: "No Prop Drilling Depth",
description: "Warn when props are drilled through too many components",
defaultSeverity: "warn",
category: "static",
icon: "🔗",
hint: "Detects excessive prop passing",
defaultEnabled: true,
defaultOptions: [
{
maxDepth: 2,
ignoredProps: ["className", "style", "children", "key", "ref", "id"],
ignoreComponents: [],
},
],
optionSchema: {
fields: [
{
key: "maxDepth",
label: "Maximum drilling depth",
type: "number",
defaultValue: 2,
description:
"Maximum number of components a prop can pass through without use",
},
{
key: "ignoredProps",
label: "Ignored props",
type: "text",
defaultValue: "className, style, children, key, ref, id",
description: "Comma-separated prop names to ignore (common pass-through props)",
},
],
},
docs: `
## What it does
Detects when props are passed through multiple intermediate components without
being used (prop drilling). This is often a sign that you should use React
Context, Zustand, or another state management solution.
## Why it's useful
- **Maintainability**: Deep prop drilling creates tight coupling
- **Refactoring**: Changes require updates in many files
- **Readability**: Hard to trace where props come from
- **Performance**: Unnecessary re-renders in intermediate components
## Examples
### ❌ Incorrect
\`\`\`tsx
// Grandparent passes user through Parent to Child
function Grandparent({ user }) {
return ;
}
function Parent({ user }) {
// Parent doesn't use 'user', just passes it along
return ;
}
function Child({ user }) {
return
{user.name}
;
}
\`\`\`
### ✅ Correct
\`\`\`tsx
// Use Context instead
const UserContext = createContext();
function Grandparent({ user }) {
return (
);
}
function Child() {
const user = useContext(UserContext);
return {user.name}
;
}
\`\`\`
## Configuration
\`\`\`js
// eslint.config.js
"uilint/no-prop-drilling-depth": ["warn", {
maxDepth: 2, // Allow passing through 2 components
ignoredProps: ["className", "style", "children"], // Common pass-through props
ignoreComponents: ["^Layout", "^Wrapper"] // Skip wrapper components
}]
\`\`\`
`,
});
/**
* Information about a component's prop usage
*/
interface ComponentPropInfo {
/** Props received by the component */
receivedProps: Set;
/** Props passed to child components: propName -> childComponentNames[] */
passedProps: Map;
/** Props actually used in the component (not just passed) */
usedProps: Set;
/** Child components that receive props from this component */
childComponents: string[];
}
/**
* Cache for analyzed component prop information
*/
const componentPropCache = new Map();
/**
* Clear the prop analysis cache
*/
export function clearPropCache(): void {
componentPropCache.clear();
}
/**
* Check if a name is a React component (PascalCase)
*/
function isComponentName(name: string): boolean {
return /^[A-Z][a-zA-Z0-9]*$/.test(name);
}
/**
* Extract props from a function parameter
*/
function extractPropsFromParam(
param: TSESTree.Parameter
): { propNames: Set; isSpread: boolean } {
const propNames = new Set();
let isSpread = false;
if (param.type === "ObjectPattern") {
for (const prop of param.properties) {
if (prop.type === "RestElement") {
isSpread = true;
} else if (
prop.type === "Property" &&
prop.key.type === "Identifier"
) {
propNames.add(prop.key.name);
}
}
} else if (param.type === "Identifier") {
// Single props parameter - assume all props accessed via props.x
isSpread = true;
}
return { propNames, isSpread };
}
/**
* Find all JSX elements in a function body and extract prop passing info
*/
function analyzeJSXPropPassing(
body: TSESTree.Node,
receivedProps: Set
): { passedProps: Map; usedProps: Set } {
const passedProps = new Map();
const usedProps = new Set();
function visit(node: TSESTree.Node): void {
if (!node || typeof node !== "object") return;
// Check JSX elements for prop passing
if (node.type === "JSXOpeningElement") {
const elementName = getJSXElementName(node.name);
// Only care about component elements (PascalCase)
if (elementName && isComponentName(elementName)) {
for (const attr of node.attributes) {
if (attr.type === "JSXAttribute" && attr.name.type === "JSXIdentifier") {
const propValue = attr.value;
// Check if the attribute value is a received prop
if (propValue?.type === "JSXExpressionContainer") {
const expr = propValue.expression;
if (expr.type === "Identifier" && receivedProps.has(expr.name)) {
// This prop is being passed to a child
const existing = passedProps.get(expr.name) || [];
existing.push(elementName);
passedProps.set(expr.name, existing);
} else if (
expr.type === "MemberExpression" &&
expr.object.type === "Identifier" &&
expr.object.name === "props" &&
expr.property.type === "Identifier"
) {
// props.x pattern
const propName = expr.property.name;
if (receivedProps.has(propName) || receivedProps.size === 0) {
const existing = passedProps.get(propName) || [];
existing.push(elementName);
passedProps.set(propName, existing);
}
}
}
}
// Check for spread props: {...props} or {...rest}
if (attr.type === "JSXSpreadAttribute") {
if (attr.argument.type === "Identifier") {
const spreadName = attr.argument.name;
if (spreadName === "props" || receivedProps.has(spreadName)) {
// All props are being spread
for (const prop of receivedProps) {
const existing = passedProps.get(prop) || [];
existing.push(elementName);
passedProps.set(prop, existing);
}
}
}
}
}
}
}
// Check for prop usage (not just passing)
// e.g., {user.name} or {props.user.name} or just {user}
if (
node.type === "MemberExpression" &&
node.object.type === "Identifier" &&
receivedProps.has(node.object.name)
) {
usedProps.add(node.object.name);
}
if (
node.type === "Identifier" &&
receivedProps.has(node.name) &&
node.parent?.type !== "JSXExpressionContainer"
) {
// Prop used in expression (but not directly passed to child)
usedProps.add(node.name);
}
// Check for props.x.something usage
if (
node.type === "MemberExpression" &&
node.object.type === "MemberExpression" &&
node.object.object.type === "Identifier" &&
node.object.object.name === "props" &&
node.object.property.type === "Identifier"
) {
usedProps.add(node.object.property.name);
}
// Recurse into children
for (const key of Object.keys(node)) {
if (key === "parent" || key === "loc" || key === "range") continue;
const child = (node as unknown as Record)[key];
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === "object") {
visit(item as TSESTree.Node);
}
}
} else if (child && typeof child === "object") {
visit(child as TSESTree.Node);
}
}
}
visit(body);
return { passedProps, usedProps };
}
/**
* Get the name of a JSX element
*/
function getJSXElementName(node: TSESTree.JSXTagNameExpression): string | null {
if (node.type === "JSXIdentifier") {
return node.name;
}
if (node.type === "JSXMemberExpression") {
// Get the root object for namespace components
let current = node.object;
while (current.type === "JSXMemberExpression") {
current = current.object;
}
return current.type === "JSXIdentifier" ? current.name : null;
}
return null;
}
export default createRule({
name: "no-prop-drilling-depth",
meta: {
type: "suggestion",
docs: {
description: "Warn when props are drilled through too many components",
},
messages: {
propDrilling:
"Prop '{{propName}}' is passed through {{depth}} component(s) without being used. Consider using Context or state management. Path: {{path}}",
},
schema: [
{
type: "object",
properties: {
maxDepth: {
type: "number",
minimum: 1,
description: "Maximum drilling depth before warning",
},
ignoredProps: {
type: "array",
items: { type: "string" },
description: "Props to ignore",
},
ignoreComponents: {
type: "array",
items: { type: "string" },
description: "Component patterns to skip (regex)",
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
maxDepth: 2,
ignoredProps: ["className", "style", "children", "key", "ref", "id"],
ignoreComponents: [],
},
],
create(context) {
const options = context.options[0] || {};
const maxDepth = options.maxDepth ?? 2;
const ignoredProps = new Set(
options.ignoredProps ?? [
"className",
"style",
"children",
"key",
"ref",
"id",
]
);
const ignoreComponentPatterns = (options.ignoreComponents ?? []).map(
(p) => new RegExp(p)
);
// Track components and their prop flows within the file
const componentProps = new Map();
const imports = new Map(); // localName -> importSource
const componentNodes = new Map(); // componentName -> node
function shouldIgnoreComponent(name: string): boolean {
return ignoreComponentPatterns.some((pattern) => pattern.test(name));
}
function shouldIgnoreProp(name: string): boolean {
return ignoredProps.has(name);
}
/**
* Analyze a component function for prop drilling
*/
function analyzeComponent(
name: string,
node: TSESTree.FunctionDeclaration | TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
reportNode: TSESTree.Node
): void {
if (shouldIgnoreComponent(name)) return;
const firstParam = node.params[0];
if (!firstParam) return;
const { propNames, isSpread } = extractPropsFromParam(firstParam);
// If using spread without destructuring, we can't easily track props
if (isSpread && propNames.size === 0) return;
const body = node.body;
if (!body) return;
const { passedProps, usedProps } = analyzeJSXPropPassing(body, propNames);
componentProps.set(name, {
receivedProps: propNames,
passedProps,
usedProps,
childComponents: [...new Set([...passedProps.values()].flat())],
});
componentNodes.set(name, reportNode);
}
return {
// Track imports for cross-file analysis
ImportDeclaration(node) {
const source = node.source.value as string;
for (const spec of node.specifiers) {
if (spec.type === "ImportSpecifier" || spec.type === "ImportDefaultSpecifier") {
imports.set(spec.local.name, source);
}
}
},
// Analyze function declarations
FunctionDeclaration(node) {
if (node.id && isComponentName(node.id.name)) {
analyzeComponent(node.id.name, node, node);
}
},
// Analyze arrow functions
VariableDeclarator(node) {
if (
node.id.type === "Identifier" &&
isComponentName(node.id.name) &&
node.init?.type === "ArrowFunctionExpression"
) {
analyzeComponent(node.id.name, node.init, node);
}
},
// Analyze at the end of the file
"Program:exit"() {
// Find drilling chains within the file
for (const [componentName, info] of componentProps) {
for (const [propName, children] of info.passedProps) {
if (shouldIgnoreProp(propName)) continue;
// Check if prop is used directly
if (info.usedProps.has(propName)) continue;
// Track the drilling chain
const chain: string[] = [componentName];
let depth = 0;
let current = children;
while (current.length > 0 && depth < maxDepth + 1) {
depth++;
const nextChildren: string[] = [];
for (const child of current) {
chain.push(child);
const childInfo = componentProps.get(child);
if (childInfo) {
// Check if child uses the prop
if (childInfo.usedProps.has(propName)) {
// Prop is used here, drilling stops
break;
}
// Check if child passes the prop further
const childPasses = childInfo.passedProps.get(propName);
if (childPasses) {
nextChildren.push(...childPasses);
}
}
}
current = nextChildren;
}
// Report if depth exceeds threshold
if (depth > maxDepth) {
const reportNode = componentNodes.get(componentName);
if (reportNode) {
context.report({
node: reportNode,
messageId: "propDrilling",
data: {
propName,
depth: String(depth),
path: chain.slice(0, maxDepth + 2).join(" → "),
},
});
}
}
}
}
},
};
},
});