diff --git a/apps/website/content/docs/migration.mdx b/apps/website/content/docs/migration.mdx
index 0f8811000d..26b301075c 100644
--- a/apps/website/content/docs/migration.mdx
+++ b/apps/website/content/docs/migration.mdx
@@ -125,7 +125,7 @@ The following table compares all rules from `eslint-plugin-react` with their ESL
| [`no-unsafe`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unsafe.md) | [`no-unsafe-component-will-mount`](/docs/rules/no-unsafe-component-will-mount) + [`no-unsafe-component-will-receive-props`](/docs/rules/no-unsafe-component-will-receive-props) + [`no-unsafe-component-will-update`](/docs/rules/no-unsafe-component-will-update) | ✅ | 🟡 |
| [`no-unstable-nested-components`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unstable-nested-components.md) | [`no-nested-component-definitions`](/docs/rules/no-nested-component-definitions) | ✅ | ✅ |
| [`no-unused-class-component-methods`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unused-class-component-methods.md) | [`no-unused-class-component-members`](/docs/rules/no-unused-class-component-members) | ✅ | 🚫 |
-| [`no-unused-prop-types`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unused-prop-types.md) | [`no-prop-types`](/docs/rules/no-prop-types) | ✅ | 🚫 |
+| [`no-unused-prop-types`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unused-prop-types.md) | [`no-unused-props`](/docs/rules/no-unused-props) | ✅ | 🚫 |
| [`no-unused-state`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unused-state.md) | [`no-unused-state`](/docs/rules/no-unused-state) | ✅ | 🚫 |
| [`no-will-update-set-state`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-will-update-set-state.md) | [`no-set-state-in-component-will-update`](/docs/rules/no-set-state-in-component-will-update) | ✅ | ✅ |
| [`prefer-es6-class`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prefer-es6-class.md) | [`no-prop-types`](/docs/rules/no-prop-types) | ✅ | 🚫 |
diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json
index 4c284c2852..cbe0000694 100644
--- a/apps/website/content/docs/rules/meta.json
+++ b/apps/website/content/docs/rules/meta.json
@@ -55,6 +55,7 @@
"no-unstable-context-value",
"no-unstable-default-props",
"no-unused-class-component-members",
+ "no-unused-props",
"no-unused-state",
"no-use-context",
"no-useless-forward-ref",
diff --git a/apps/website/content/docs/rules/overview.mdx b/apps/website/content/docs/rules/overview.mdx
index 0f8c8c7194..a708e0d235 100644
--- a/apps/website/content/docs/rules/overview.mdx
+++ b/apps/website/content/docs/rules/overview.mdx
@@ -82,6 +82,7 @@ The `jsx-*` rules check for issues exclusive to JSX syntax, which are absent fro
| [`no-unstable-context-value`](./no-unstable-context-value) | 1️⃣ | | Prevents non-stable values (i.e. object literals) from being used as a value for `Context.Provider` | |
| [`no-unstable-default-props`](./no-unstable-default-props) | 1️⃣ | | Prevents using referential-type values as default props in object destructuring | |
| [`no-unused-class-component-members`](./no-unused-class-component-members) | 1️⃣ | | Warns unused class component methods and properties | |
+| [`no-unused-props`](./no-unused-props) | 0️⃣ | | Warns about unused component prop declarations | |
| [`no-unused-state`](./no-unused-state) | 1️⃣ | | Warns unused class component state | |
| [`no-use-context`](./no-use-context) | 1️⃣ | `🔄` | Replaces usages of `useContext` with `use` | >=19.0.0 |
| [`no-useless-forward-ref`](./no-useless-forward-ref) | 1️⃣ | | Disallow useless `forwardRef` calls on components that don't use `ref`s | |
diff --git a/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts b/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts
index cc4ba5ae27..7a3ad3db90 100644
--- a/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts
@@ -56,6 +56,7 @@ export const rules = {
"react-x/no-unstable-context-value": "warn",
"react-x/no-unstable-default-props": "warn",
"react-x/no-unused-class-component-members": "warn",
+ // "react-x/no-unused-props": "warn",
"react-x/no-unused-state": "warn",
"react-x/no-use-context": "warn",
"react-x/no-useless-forward-ref": "warn",
diff --git a/packages/plugins/eslint-plugin-react-x/src/plugin.ts b/packages/plugins/eslint-plugin-react-x/src/plugin.ts
index 29ceebec85..5df3b29b6a 100644
--- a/packages/plugins/eslint-plugin-react-x/src/plugin.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/plugin.ts
@@ -51,6 +51,7 @@ import noUnsafeComponentWillUpdate from "./rules/no-unsafe-component-will-update
import noUnstableContextValue from "./rules/no-unstable-context-value";
import noUnstableDefaultProps from "./rules/no-unstable-default-props";
import noUnusedClassComponentMembers from "./rules/no-unused-class-component-members";
+import noUnusedProps from "./rules/no-unused-props";
import noUnusedState from "./rules/no-unused-state";
import noUseContext from "./rules/no-use-context";
import noUselessForwardRef from "./rules/no-useless-forward-ref";
@@ -127,6 +128,7 @@ export const plugin = {
"no-unstable-context-value": noUnstableContextValue,
"no-unstable-default-props": noUnstableDefaultProps,
"no-unused-class-component-members": noUnusedClassComponentMembers,
+ "no-unused-props": noUnusedProps,
"no-unused-state": noUnusedState,
"no-use-context": noUseContext,
"no-useless-forward-ref": noUselessForwardRef,
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.md b/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.md
new file mode 100644
index 0000000000..5fd8990c65
--- /dev/null
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.md
@@ -0,0 +1,81 @@
+---
+title: no-unused-props
+---
+
+**Full Name in `eslint-plugin-react-x`**
+
+```sh copy
+react-x/no-unused-props
+```
+
+**Full Name in `@eslint-react/eslint-plugin`**
+
+```sh copy
+@eslint-react/no-unused-props
+```
+
+## Description
+
+Warns about unused component prop declarations.
+
+Unused props increase maintenance overhead and may mislead consumers of the component into thinking the prop is required or meaningful, even when it has no effect.
+
+This is the TypeScript-only version of [`eslint-plugin-react/no-unused-prop-types`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unused-prop-types.md). In contrast to the original rule, this rule
+
+- doesn't support the legacy propTypes syntax
+- combines the used props of one type definition declared by multiple components
+
+## Examples
+
+### Failing
+
+```tsx
+interface Props {
+ abc: string; // used
+ hello: string; // NOT used
+}
+
+function Component(props: Props) {
+ const { abc } = props; // `hello` isn't accessed from `props`
+ return null;
+}
+```
+
+### Passing
+
+```tsx
+interface Props {
+ abc: string; // used
+ hello: string; // used
+}
+
+function Component(props: Props) {
+ const { abc, hello } = props;
+ return null;
+}
+```
+
+```tsx
+interface Props {
+ abc: string; // used by Component1
+ hello: string; // used by Component2
+}
+
+function Component1({ abc }: Props) {
+ return null;
+}
+
+function Component2({ hello }: Props) {
+ return null;
+}
+```
+
+## Implementation
+
+- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.ts)
+- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.spec.ts)
+
+## See Also
+
+- [`no-prop-types`](/docs/rules/no-prop-types)\
+ Disallows `propTypes`
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.spec.ts
new file mode 100644
index 0000000000..726e6946e1
--- /dev/null
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.spec.ts
@@ -0,0 +1,811 @@
+import tsx from "dedent";
+
+import { allValid, ruleTesterWithTypes } from "../../../../../test";
+import rule, { RULE_NAME } from "./no-unused-props";
+
+ruleTesterWithTypes.run(RULE_NAME, rule, {
+ invalid: [
+ {
+ // interface type and later destructuring
+ code: tsx`
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ const { abc } = props;
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 3,
+ line: 3,
+ }],
+ },
+ {
+ // interface type and direct destructuring
+ code: tsx`
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component({ abc }: Props) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 3,
+ line: 3,
+ }],
+ },
+ {
+ // named type and later destructuring
+ code: tsx`
+ type Props = {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ const { abc } = props;
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 3,
+ line: 3,
+ }],
+ },
+ {
+ // interface type and direct destructuring
+ code: tsx`
+ type Props = {
+ abc: string;
+ hello: string;
+ }
+
+ function Component({ abc }: Props) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 3,
+ line: 3,
+ }],
+ },
+ {
+ // inline type and later destructuring
+ code: tsx`
+ function Component(props: { abc: string; hello: string; }) {
+ const { abc } = props;
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 42,
+ data: {
+ name: "hello",
+ },
+ endColumn: 47,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // inline type and direct destructuring
+ code: tsx`
+ function Component({ abc }: { abc: string; hello: string; }) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 44,
+ data: {
+ name: "hello",
+ },
+ endColumn: 49,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // multiple properties unused
+ code: tsx`
+ function Component({ }: { abc: string; hello: string; }) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 27,
+ data: {
+ name: "abc",
+ },
+ endColumn: 30,
+ endLine: 1,
+ line: 1,
+ }, {
+ messageId: "noUnusedProps",
+ column: 40,
+ data: {
+ name: "hello",
+ },
+ endColumn: 45,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // interface augmentation
+ code: tsx`
+ interface Props {
+ used1: string;
+ abc: string;
+ }
+
+ interface Props {
+ used2: string;
+ hello: string;
+ }
+
+ function Component({ used1, used2 }: Props) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "abc",
+ },
+ endColumn: 6,
+ endLine: 3,
+ line: 3,
+ }, {
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 8,
+ line: 8,
+ }],
+ },
+ {
+ // interface union
+ code: tsx`
+ interface Props1 {
+ used1: string;
+ abc: string;
+ }
+
+ interface Props2 {
+ used2: string;
+ hello: string;
+ }
+
+ function Component({ used1, used2 }: Props1 & Props2) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "abc",
+ },
+ endColumn: 6,
+ endLine: 3,
+ line: 3,
+ }, {
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 8,
+ line: 8,
+ }],
+ },
+ {
+ // interface extends
+ code: tsx`
+ interface PropsBase {
+ used1: string;
+ abc: string;
+ }
+
+ interface Props extends PropsBase {
+ used2: string;
+ hello: string;
+ }
+
+ function Component({ used1, used2 }: Props) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "abc",
+ },
+ endColumn: 6,
+ endLine: 3,
+ line: 3,
+ }, {
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "hello",
+ },
+ endColumn: 8,
+ endLine: 8,
+ line: 8,
+ }],
+ },
+ {
+ // track uses of properties on rest element
+ code: tsx`
+ function Component({ ...rest }: { abc: string; hello: string; }) {
+ return
{rest.abc}
;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 48,
+ data: {
+ name: "hello",
+ },
+ endColumn: 53,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // track uses of properties on rest element
+ code: tsx`
+ function Component(props: { abc: string; hello: string; }) {
+ const { ...rest } = props;
+ return {rest.abc}
;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 42,
+ data: {
+ name: "hello",
+ },
+ endColumn: 47,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // track assignment
+ code: tsx`
+ function Component(props: { abc: string; hello: string; }) {
+ const abc = props.abc;
+ return {abc}
;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 42,
+ data: {
+ name: "hello",
+ },
+ endColumn: 47,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // track computed member access
+ code: tsx`
+ function Component(props: { abc: string; hello: string; }) {
+ return {props["abc"]}
;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 42,
+ data: {
+ name: "hello",
+ },
+ endColumn: 47,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // correct error span on complex prop type
+ code: tsx`
+ function Component({ abc }: { abc: string; hello: { abc: string; subHello: number | null }; }) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 44,
+ data: {
+ name: "hello",
+ },
+ endColumn: 49,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // access of sub property should mark property as used
+ code: tsx`
+ function Component({ hello: { subHello } }: { abc: string; hello: { abc: string; subHello: number | null }; }) {
+ return null;
+ }
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 47,
+ data: {
+ name: "abc",
+ },
+ endColumn: 50,
+ endLine: 1,
+ line: 1,
+ }],
+ },
+ {
+ // expect no false negatives when using PropsWithChildren
+ code: tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = {
+ backgroundColor : string;
+ onClick: () => void;
+ };
+
+ const Button = ({ backgroundColor }: PropsWithChildren) => {
+ return (
+
+ );
+ };
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "onClick",
+ },
+ endColumn: 10,
+ endLine: 5,
+ line: 5,
+ }],
+ },
+ {
+ // expect no false negatives when using PropsWithChildren
+ code: tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = PropsWithChildren<{
+ backgroundColor : string;
+ onClick: () => void;
+ }>;
+
+ const Button = ({ backgroundColor }: ButtonProps) => {
+ return (
+
+ );
+ };
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "onClick",
+ },
+ endColumn: 10,
+ endLine: 5,
+ line: 5,
+ }],
+ },
+ {
+ // expect no false negatives when using PropsWithChildren
+ code: tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = {
+ backgroundColor : string;
+ onClick: () => void;
+ };
+
+ const Button = ({ backgroundColor }: ButtonProps & PropsWithChildren) => {
+ return (
+
+ );
+ };
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "onClick",
+ },
+ endColumn: 10,
+ endLine: 5,
+ line: 5,
+ }],
+ },
+ {
+ // expect no false negatives when using PropsWithChildren
+ code: tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = {
+ backgroundColor : string;
+ onClick: () => void;
+ } & PropsWithChildren;
+
+ const Button = ({ backgroundColor }: ButtonProps) => {
+ return (
+
+ );
+ };
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "onClick",
+ },
+ endColumn: 10,
+ endLine: 5,
+ line: 5,
+ }],
+ },
+ {
+ // expect no false negatives when using forwardRef
+ code: tsx`
+ import * as React from 'react'
+ interface ComponentProps {
+ foo: string;
+ }
+ const Component = React.forwardRef(function Component(props, ref) {
+ return ;
+ });
+ `,
+ errors: [{
+ messageId: "noUnusedProps",
+ column: 3,
+ data: {
+ name: "foo",
+ },
+ endColumn: 6,
+ endLine: 3,
+ line: 3,
+ }],
+ },
+ // TODO: Should we report unused ref prop?
+ // {
+ // // expect no false negatives when using ref as a prop
+ // code: tsx`
+ // import * as React from 'react'
+ // interface ComponentProps {
+ // foo: string;
+ // }
+ // const Component = function Component({ ref, ...props }: ComponentProps & { ref?: React.RefObject }) {
+ // return {props.foo}
;
+ // };
+ // `,
+ // errors: [{
+ // messageId: "noUnusedProps",
+ // data: {
+ // name: "ref",
+ // },
+ // }],
+ // },
+ ],
+ valid: [
+ // all props are used
+ tsx`
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ const { abc, hello } = props;
+ return null;
+ }
+ `,
+ // all props are used
+ tsx`
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component({ abc, hello }: Props) {
+ return null;
+ }
+ `,
+ // all props are used
+ tsx`
+ type Props = {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ const { abc, hello } = props;
+ return null;
+ }
+ `,
+ // all props are used
+ tsx`
+ type Props = {
+ abc: string;
+ hello: string;
+ }
+
+ function Component({ abc, hello }: Props) {
+ return null;
+ }
+ `,
+ // all props are used
+ tsx`
+ function Component(props: { abc: string; hello: string; }) {
+ const { abc, hello } = props;
+ return null;
+ }
+ `,
+ // all props are used
+ tsx`
+ function Component({ abc, hello }: { abc: string; hello: string; }) {
+ return null;
+ }
+ `,
+ // all props are used
+ tsx`
+ function Component({ abc: abc2, hello: hello2 }: { abc: string; hello: string; }) {
+ return null;
+ }
+ `,
+ // props are used by two components each accessing one prop
+ tsx`
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component1({ abc }: Props) {
+ return null;
+ }
+
+ function Component2({ hello }: Props) {
+ return null;
+ }
+ `,
+ // props are used by two components each accessing a different part of it
+ tsx`
+ interface Props {
+ foo: string;
+ bar: string;
+ baz: string;
+ }
+
+ function Component1({ foo, bar }: Props) {
+ return {foo}
;
+ }
+
+ function Component2({ bar, baz }: Props) {
+ return {bar}
;
+ }
+ `,
+ // we can't track what happens to the props object
+ tsx`
+ import { Component2 } from "./component2";
+
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ return ;
+ }
+ `,
+ // we can't track what happens to the props object
+ tsx`
+ import { anyFunction } from "./anyFunction";
+
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ anyFunction(props);
+
+ return null;
+ }
+ `,
+ // we can't track what happens to the props object
+ tsx`
+ import { anyFunction } from "./anyFunction";
+
+ interface Props {
+ abc: string;
+ hello: string;
+ }
+
+ function Component(props: Props) {
+ anyFunction({ props });
+
+ return null;
+ }
+ `,
+ // one value used in jsx, the other in effect
+ tsx`
+ import { useEffect } from "react";
+
+ function Component({ abc, hello }: { abc: string; hello: string }) {
+ useEffect(() => {
+ console.log(hello);
+ }, []);
+ return {abc}
;
+ }
+ `,
+ // we can't track what happens to the rest object
+ tsx`
+ import { anyFunction } from "./anyFunction";
+
+ function Component({ abc, ...rest }: { abc: string; hello: string }) {
+ anyFunction(rest);
+ return null;
+ }
+ `,
+ // we can't track what happens to the rest object
+ tsx`
+ import { anyFunction } from "./anyFunction";
+
+ function Component(props: { abc: string; hello: string; }) {
+ const { abc, ...rest } = props;
+ anyFunction(rest);
+ return null;
+ }
+ `,
+ // props used inside nested function
+ tsx`
+ function Component(props: { abc: string; hello: string }) {
+ function inner() {
+ return props.hello;
+ }
+ return props.abc;
+ }
+ `,
+ // props used conditionally
+ tsx`
+ function Component(props: { abc: string; hello: string }) {
+ if (Math.random() > 0.5) {
+ return {props.abc}
;
+ }
+ return {props.hello}
;
+ }
+ `,
+ // expect no false positives when using PropsWithChildren
+ tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = {
+ backgroundColor : string;
+ onClick: () => void;
+ };
+
+ const Button = ({ backgroundColor, onClick, children }: PropsWithChildren) => {
+ return (
+
+ );
+ };
+ `,
+ // expect no false positives when using PropsWithChildren
+ tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = PropsWithChildren<{
+ backgroundColor : string;
+ onClick: () => void;
+ }>;
+
+ const Button = ({ backgroundColor, onClick, children }: ButtonProps) => {
+ return (
+
+ );
+ };
+ `,
+ // TODO: Should we report unused children prop when using PropsWithChildren? currently we don't
+ tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = {
+ backgroundColor : string;
+ onClick: () => void;
+ };
+
+ const Button = ({ backgroundColor, onClick }: PropsWithChildren) => {
+ return (
+
+ );
+ };
+ `,
+ // TODO: Should we report unused children prop when using PropsWithChildren? currently we don't
+ tsx`
+ import { PropsWithChildren } from 'react';
+
+ type ButtonProps = PropsWithChildren<{
+ backgroundColor : string;
+ onClick: () => void;
+ }>;
+
+ const Button = ({ backgroundColor, onClick }: ButtonProps) => {
+ return (
+
+ );
+ };
+ `,
+ // expect no false positives when using forwardRef
+ tsx`
+ import * as React from 'react'
+ interface ComponentProps {
+ foo: string;
+ }
+ const Component = React.forwardRef(function Component(props, ref) {
+ return {props.foo}
;
+ });
+ `,
+ // expect no false positives when using ref as a prop
+ tsx`
+ import * as React from 'react'
+ interface ComponentProps {
+ foo: string;
+ }
+ const Component = function Component({ ref, ...props }: ComponentProps & { ref?: React.RefObject }) {
+ return {props.foo}
;
+ };
+ `,
+ ...allValid,
+ ],
+});
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.ts
new file mode 100644
index 0000000000..f1ef69f1a2
--- /dev/null
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unused-props.ts
@@ -0,0 +1,236 @@
+import type { RuleContext, RuleFeature } from "@eslint-react/kit";
+import type { Reference } from "@typescript-eslint/scope-manager";
+import type { TSESTree } from "@typescript-eslint/types";
+import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
+import type { CamelCase } from "string-ts";
+import type ts from "typescript";
+import * as ER from "@eslint-react/core";
+import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
+import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript-eslint/utils";
+
+import { createRule } from "../utils";
+
+export const RULE_NAME = "no-unused-props";
+
+export const RULE_FEATURES = ["TSC", "EXP"] as const satisfies RuleFeature[];
+
+export type MessageID = CamelCase;
+
+export default createRule<[], MessageID>({
+ meta: {
+ type: "problem",
+ docs: {
+ description: "Warns about unused component prop declarations.",
+ [Symbol.for("rule_features")]: RULE_FEATURES,
+ },
+ messages: {
+ noUnusedProps: "Prop `{{name}}` is declared but never used",
+ },
+ schema: [],
+ },
+ name: RULE_NAME,
+ create,
+ defaultOptions: [],
+});
+
+export function create(context: RuleContext): RuleListener {
+ const services = ESLintUtils.getParserServices(context, false);
+ const { ctx, listeners } = ER.useComponentCollector(context);
+
+ return {
+ ...listeners,
+ "Program:exit"(program) {
+ const checker = services.program.getTypeChecker();
+ const components = ctx.getAllComponents(program);
+
+ const totalDeclaredProps = new Set();
+ const totalUsedProps = new Set();
+
+ for (const [, component] of components) {
+ const [props] = component.node.params;
+ if (props == null) continue;
+
+ const usedPropKeys = new Set();
+ const couldFindAllUsedPropKeys = collectUsedPropKeysOfParameter(context, usedPropKeys, props);
+ if (!couldFindAllUsedPropKeys) {
+ // unable to determine all used prop keys => bail out to avoid false positives
+ continue;
+ }
+
+ const tsNode = services.esTreeNodeToTSNodeMap.get(props);
+ const declaredProps = checker.getTypeAtLocation(tsNode).getProperties();
+
+ for (const declaredProp of declaredProps) {
+ totalDeclaredProps.add(declaredProp);
+
+ if (usedPropKeys.has(declaredProp.name)) {
+ totalUsedProps.add(declaredProp);
+ }
+ }
+ }
+
+ // TODO: Node 20 doesn't support Set.difference. Use it when minimum Node version is 22.
+ const unusedProps = [...totalDeclaredProps].filter((x) => !totalUsedProps.has(x));
+
+ for (const unusedProp of unusedProps) {
+ reportUnusedProp(context, services, unusedProp);
+ }
+ },
+ };
+}
+
+function collectUsedPropKeysOfParameter(
+ context: RuleContext,
+ usedPropKeys: Set,
+ parameter: TSESTree.Parameter,
+): boolean {
+ switch (parameter.type) {
+ case T.Identifier: {
+ return collectUsedPropKeysOfIdentifier(context, usedPropKeys, parameter);
+ }
+ case T.ObjectPattern: {
+ return collectUsedPropKeysOfObjectPattern(context, usedPropKeys, parameter);
+ }
+ default: {
+ return false;
+ }
+ }
+}
+
+function collectUsedPropKeysOfObjectPattern(
+ context: RuleContext,
+ usedPropKeys: Set,
+ objectPattern: TSESTree.ObjectPattern,
+): boolean {
+ for (const property of objectPattern.properties) {
+ switch (property.type) {
+ case T.Property: {
+ const key = getKeyOfExpression(property.key);
+ if (key == null) return false;
+ usedPropKeys.add(key);
+ break;
+ }
+ case T.RestElement: {
+ if (!collectUsedPropsOfRestElement(context, usedPropKeys, property)) {
+ return false;
+ }
+ break;
+ }
+ }
+ }
+
+ return true;
+}
+
+function collectUsedPropsOfRestElement(
+ context: RuleContext,
+ usedPropKeys: Set,
+ restElement: TSESTree.RestElement,
+): boolean {
+ switch (restElement.argument.type) {
+ case T.Identifier: {
+ return collectUsedPropKeysOfIdentifier(context, usedPropKeys, restElement.argument);
+ }
+ default: {
+ return false;
+ }
+ }
+}
+
+function collectUsedPropKeysOfIdentifier(
+ context: RuleContext,
+ usedPropKeys: Set,
+ identifier: TSESTree.Identifier,
+): boolean {
+ const scope = context.sourceCode.getScope(identifier);
+ const variable = scope.variables.find((v) => v.name === identifier.name);
+ if (variable == null) return false;
+
+ for (const ref of variable.references) {
+ if (ref.identifier === identifier) {
+ continue;
+ }
+
+ if (!collectUsedPropKeysOfReference(context, usedPropKeys, identifier, ref)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function collectUsedPropKeysOfReference(
+ context: RuleContext,
+ usedPropKeys: Set,
+ identifier: TSESTree.Identifier,
+ ref: Reference,
+): boolean {
+ const { parent } = ref.identifier;
+
+ switch (parent.type) {
+ case T.MemberExpression: {
+ if (
+ parent.object.type === T.Identifier
+ && parent.object.name === identifier.name
+ ) {
+ const key = getKeyOfExpression(parent.property);
+ if (key == null) return false;
+ usedPropKeys.add(key);
+ return true;
+ }
+ break;
+ }
+ case T.VariableDeclarator: {
+ if (
+ parent.id.type === T.ObjectPattern
+ && parent.init === ref.identifier
+ ) {
+ return collectUsedPropKeysOfObjectPattern(context, usedPropKeys, parent.id);
+ }
+ break;
+ }
+ }
+
+ return false;
+}
+
+function getKeyOfExpression(
+ expr: TSESTree.Expression | TSESTree.PrivateIdentifier,
+): string | null {
+ switch (expr.type) {
+ case T.Identifier: {
+ return expr.name;
+ }
+ case T.Literal: {
+ if (typeof expr.value === "string") {
+ return expr.value;
+ }
+ }
+ }
+
+ return null;
+}
+
+function reportUnusedProp(
+ context: RuleContext,
+ services: ParserServicesWithTypeInformation,
+ prop: ts.Symbol,
+) {
+ const declaration = prop.getDeclarations()?.[0];
+ if (declaration == null) return;
+
+ const declarationNode = services.tsNodeToESTreeNodeMap.get(declaration);
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (declarationNode == null) return; // is undefined if declaration is in a different file
+
+ const nodeToReport = declarationNode.type === T.TSPropertySignature
+ ? declarationNode.key
+ : declarationNode;
+
+ context.report({
+ messageId: "noUnusedProps",
+ node: nodeToReport,
+ data: { name: prop.name },
+ });
+}
diff --git a/packages/plugins/eslint-plugin/src/configs/all.ts b/packages/plugins/eslint-plugin/src/configs/all.ts
index a477b625ab..9167089203 100644
--- a/packages/plugins/eslint-plugin/src/configs/all.ts
+++ b/packages/plugins/eslint-plugin/src/configs/all.ts
@@ -63,6 +63,7 @@ export const rules = {
"@eslint-react/no-unstable-context-value": "warn",
"@eslint-react/no-unstable-default-props": "warn",
"@eslint-react/no-unused-class-component-members": "warn",
+ // "@eslint-react/no-unused-props": "warn",
"@eslint-react/no-unused-state": "warn",
"@eslint-react/no-use-context": "warn",
"@eslint-react/no-useless-forward-ref": "warn",
diff --git a/packages/plugins/eslint-plugin/src/configs/x.ts b/packages/plugins/eslint-plugin/src/configs/x.ts
index 5b0fe57670..46b95f688f 100644
--- a/packages/plugins/eslint-plugin/src/configs/x.ts
+++ b/packages/plugins/eslint-plugin/src/configs/x.ts
@@ -57,6 +57,7 @@ export const rules = {
"@eslint-react/no-unstable-context-value": "warn",
"@eslint-react/no-unstable-default-props": "warn",
"@eslint-react/no-unused-class-component-members": "warn",
+ // "@eslint-react/no-unused-props": "warn",
"@eslint-react/no-unused-state": "warn",
"@eslint-react/no-use-context": "warn",
"@eslint-react/no-useless-forward-ref": "warn",