Repository: sveltejs/eslint-plugin-svelte
Pull request #1062 (2025-02-09 08:23:25)

feat: add `no-unnecessary-state-wrap` rule
close: https://github.com/sveltejs/eslint-plugin-svelte/issues/1052

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 219a5e55bcf32ddfd326925bf1719c56a52b7681

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                 | Type  |
| -------------------- | ----- |
| eslint-plugin-svelte | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/sveltejs/eslint-plugin-svelte/new/feat/1052?filename=.changeset/thick-dragons-doubt.md&value=---%0A%22eslint-plugin-svelte%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20%60no-unnecessary-state-wrap%60%20rule%0A)


github-actions[bot]: <!-- posted by pkg.pr.new-comment.mjs -->

## Try the Instant Preview in Online Playground

[ESLint Online Playground](https://eslint-online-playground.netlify.app/?overrideDeps=%7B%22eslint-plugin-svelte%22%3A%22https%3A%2F%2Fpkg.pr.new%2Feslint-plugin-svelte%401062%22%7D#eslint-plugin-svelte%20with%20typescript)

## Install the Instant Preview to Your Local

```
npm i https://pkg.pr.new/eslint-plugin-svelte@1062
```

## Published Instant Preview Packages:

- eslint-plugin-svelte: https://pkg.pr.new/eslint-plugin-svelte@1062

[View Commit](https://github.com/sveltejs/eslint-plugin-svelte/commit/4b97e527231d58056eaea25e371f87030e320660)
marekdedic: Hi,
sorry if this is unfinished and you're planning to add this, but atm, this ignores https://github.com/sveltejs/eslint-plugin-svelte/issues/1052#issuecomment-2630480779
baseballyama: Since the `clear` function can be used, `$state` is usually unnecessary. If there is a reason to use `$state(new SvelteMap())`, this rule can simply be disabled. ESLint is fully configurable.
marekdedic: I still have to disagree - the rule now has a clear false positive that can be detected by its implementation, but just isn't.
And I'm sorry, but yeah, you can disable any rule for any line, but if the FP can be detected by the rule, it should be. Only when it's not possible (like what we're discussing in #910 ) should users be forced to manually disable rules...
baseballyama: Can you provide an example of fp?
marekdedic: Of course, see https://github.com/sveltejs/eslint-plugin-svelte/issues/1052#issuecomment-2630480779 for a simple example or [here](https://github.com/scout-handbook/handbook-web-admin/blob/2bd402626049e75af7a6d7b9c0b1f7a75b6bff2e/src/lib/resources/fields.svelte.ts#L11-L25) for a real-world example (this one is in `$derived`, but the same semantics apply):
baseballyama: Then, let’s add an option to not report an error when a reassignment exists.
marekdedic: Thanks :) I don't even think it needs to be an option - if a `$state(SvelteMap)` is reassigned, the `$state` is always necessary...
baseballyama: I don’t think so. I believe it might simply be a mistake. At least in my project, I want to consistently treat it as an error. For example, in the following case, it can clearly use “clear”.

```js
  let map = $state(new SvelteMap());
  map = new SvelteMap();
```
marekdedic: Hmm, you are absolutely right that the code snippet is incorrect, thanks, I din't realize that!

I am thinking if this isn't a separate issue, though - the anti-pattern here is IMO assigning a `new SvelteMap()` to an object which is a `SvelteMap` instead of using `.clear()`. And that would also be an issue with the regular `Map`, wouldn't it? So maybe you found a more general issue here that assigning `new X()` to a variable of type `X` is incorrect and can be replaced with `X.clear()` for X being any of `SvelteMap`, `SvelteSet`, `Map`, `Set` and possibly more...

Now if that were a separate rule it would nicely dovetail with this one even if this rule ignored all re-assigned variables, the other rule would replace the re-assignment with `.clear()` and this rule would then remove the `$state`.

I feel I am rambling a little bit - do I make any sense to you? :D
baseballyama: In any case, for those who don’t want this rule enabled, they can simply disable it. In most cases, `$state(new SvelteMap())` doesn’t really make sense, so I think having it enabled by default isn’t a bad idea.
baseballyama: I added `allowReassign` option.

Patch:
diff --git a/.changeset/popular-needles-remain.md b/.changeset/popular-needles-remain.md
new file mode 100644
index 0000000..9fc4970
--- /dev/null
+++ b/.changeset/popular-needles-remain.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-svelte': minor
+---
+
+feat: add `no-unnecessary-state-wrap` rule
diff --git a/README.md b/README.md
index a097287..3315bd3 100644
--- a/README.md
+++ b/README.md
@@ -302,6 +302,7 @@ These rules relate to better ways of doing things to help you avoid problems:
 | [svelte/no-reactive-functions](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-functions/) | it's not necessary to define functions in reactive statements | :star::bulb: |
 | [svelte/no-reactive-literals](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :star::bulb: |
 | [svelte/no-svelte-internal](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-svelte-internal/) | svelte/internal will be removed in Svelte 6. | :star: |
+| [svelte/no-unnecessary-state-wrap](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unnecessary-state-wrap/) | Disallow unnecessary $state wrapping of reactive classes | :star::bulb: |
 | [svelte/no-unused-class-name](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-class-name/) | disallow the use of a class in the template without a corresponding style |  |
 | [svelte/no-unused-props](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-props/) | Warns about defined Props properties that are unused | :star: |
 | [svelte/no-unused-svelte-ignore](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-svelte-ignore/) | disallow unused svelte-ignore comments | :star: |
diff --git a/docs/rules.md b/docs/rules.md
index ac26d5e..58d896b 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -59,6 +59,7 @@ These rules relate to better ways of doing things to help you avoid problems:
 | [svelte/no-reactive-functions](./rules/no-reactive-functions.md)                         | it's not necessary to define functions in reactive statements                                                                             | :star::bulb:   |
 | [svelte/no-reactive-literals](./rules/no-reactive-literals.md)                           | don't assign literal values in reactive statements                                                                                        | :star::bulb:   |
 | [svelte/no-svelte-internal](./rules/no-svelte-internal.md)                               | svelte/internal will be removed in Svelte 6.                                                                                              | :star:         |
+| [svelte/no-unnecessary-state-wrap](./rules/no-unnecessary-state-wrap.md)                 | Disallow unnecessary $state wrapping of reactive classes                                                                                  | :star::bulb:   |
 | [svelte/no-unused-class-name](./rules/no-unused-class-name.md)                           | disallow the use of a class in the template without a corresponding style                                                                 |                |
 | [svelte/no-unused-props](./rules/no-unused-props.md)                                     | Warns about defined Props properties that are unused                                                                                      | :star:         |
 | [svelte/no-unused-svelte-ignore](./rules/no-unused-svelte-ignore.md)                     | disallow unused svelte-ignore comments                                                                                                    | :star:         |
diff --git a/docs/rules/no-unnecessary-state-wrap.md b/docs/rules/no-unnecessary-state-wrap.md
new file mode 100644
index 0000000..8e10ae3
--- /dev/null
+++ b/docs/rules/no-unnecessary-state-wrap.md
@@ -0,0 +1,115 @@
+---
+pageClass: 'rule-details'
+sidebarDepth: 0
+title: 'svelte/no-unnecessary-state-wrap'
+description: 'Disallow unnecessary $state wrapping of reactive classes'
+---
+
+# svelte/no-unnecessary-state-wrap
+
+> Disallow unnecessary $state wrapping of reactive classes
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
+- :gear: This rule is included in `"plugin:svelte/recommended"`.
+- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
+
+## :book: Rule Details
+
+In Svelte 5, several built-in classes from `svelte/reactivity` are already reactive by default:
+
+- `SvelteSet`
+- `SvelteMap`
+- `SvelteURL`
+- `SvelteURLSearchParams`
+- `SvelteDate`
+- `MediaQuery`
+
+Therefore, wrapping them with `$state` is unnecessary and can lead to confusion.
+
+<!--eslint-skip-->
+
+```svelte
+<script>
+  /* eslint svelte/no-unnecessary-state-wrap: "error" */
+  import {
+    SvelteSet,
+    SvelteMap,
+    SvelteURL,
+    SvelteURLSearchParams,
+    SvelteDate,
+    MediaQuery
+  } from 'svelte/reactivity';
+
+  // ✓ GOOD
+  const set1 = new SvelteSet();
+  const map1 = new SvelteMap();
+  const url1 = new SvelteURL('https://example.com');
+  const params1 = new SvelteURLSearchParams('key=value');
+  const date1 = new SvelteDate();
+  const mediaQuery1 = new MediaQuery('(min-width: 800px)');
+
+  // ✗ BAD
+  const set2 = $state(new SvelteSet());
+  const map2 = $state(new SvelteMap());
+  const url2 = $state(new SvelteURL('https://example.com'));
+  const params2 = $state(new SvelteURLSearchParams('key=value'));
+  const date2 = $state(new SvelteDate());
+  const mediaQuery2 = $state(new MediaQuery('(min-width: 800px)'));
+</script>
+```
+
+## :wrench: Options
+
+```json
+{
+  "svelte/no-unnecessary-state-wrap": [
+    "error",
+    {
+      "additionalReactiveClasses": [],
+      "allowReassign": false
+    }
+  ]
+}
+```
+
+- `additionalReactiveClasses` ... An array of class names that should also be considered reactive. This is useful when you have custom classes that are inherently reactive. Default is `[]`.
+- `allowReassign` ... If `true`, allows `$state` wrapping of reactive classes when the variable is reassigned. Default is `false`.
+
+### Examples with Options
+
+#### `additionalReactiveClasses`
+
+```svelte
+<script>
+  /* eslint svelte/no-unnecessary-state-wrap: ["error", { "additionalReactiveClasses": ["MyReactiveClass"] }] */
+  import { MyReactiveClass } from './foo';
+
+  // ✓ GOOD
+  const myState1 = new MyReactiveClass();
+
+  // ✗ BAD
+  const myState2 = $state(new MyReactiveClass());
+</script>
+```
+
+#### `allowReassign`
+
+```svelte
+<script>
+  /* eslint svelte/no-unnecessary-state-wrap: ["error", { "allowReassign": true }] */
+  import { SvelteSet } from 'svelte/reactivity';
+
+  // ✓ GOOD
+  let set1 = $state(new SvelteSet());
+  set1 = new SvelteSet([1, 2, 3]); // Variable is reassigned
+
+  // ✗ BAD
+  const set2 = $state(new SvelteSet()); // const cannot be reassigned
+  let set3 = $state(new SvelteSet()); // Variable is never reassigned
+</script>
+```
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/no-unnecessary-state-wrap.ts)
+- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/no-unnecessary-state-wrap.ts)
diff --git a/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts b/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts
index 4582266..b1b2d51 100644
--- a/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts
+++ b/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts
@@ -32,6 +32,7 @@ const config: Linter.Config[] = [
 			'svelte/no-store-async': 'error',
 			'svelte/no-svelte-internal': 'error',
 			'svelte/no-unknown-style-directive-property': 'error',
+			'svelte/no-unnecessary-state-wrap': 'error',
 			'svelte/no-unused-props': 'error',
 			'svelte/no-unused-svelte-ignore': 'error',
 			'svelte/no-useless-children-snippet': 'error',
diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts
index f0bc2b2..86e6d65 100644
--- a/packages/eslint-plugin-svelte/src/rule-types.ts
+++ b/packages/eslint-plugin-svelte/src/rule-types.ts
@@ -256,6 +256,11 @@ export interface RuleOptions {
    * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unknown-style-directive-property/
    */
   'svelte/no-unknown-style-directive-property'?: Linter.RuleEntry<SvelteNoUnknownStyleDirectiveProperty>
+  /**
+   * Disallow unnecessary $state wrapping of reactive classes
+   * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unnecessary-state-wrap/
+   */
+  'svelte/no-unnecessary-state-wrap'?: Linter.RuleEntry<SvelteNoUnnecessaryStateWrap>
   /**
    * disallow the use of a class in the template without a corresponding style
    * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-class-name/
@@ -518,6 +523,11 @@ type SvelteNoUnknownStyleDirectiveProperty = []|[{
   ignoreProperties?: [string, ...(string)[]]
   ignorePrefixed?: boolean
 }]
+// ----- svelte/no-unnecessary-state-wrap -----
+type SvelteNoUnnecessaryStateWrap = []|[{
+  additionalReactiveClasses?: string[]
+  allowReassign?: boolean
+}]
 // ----- svelte/no-unused-class-name -----
 type SvelteNoUnusedClassName = []|[{
   allowedClassNames?: string[]
diff --git a/packages/eslint-plugin-svelte/src/rules/no-unnecessary-state-wrap.ts b/packages/eslint-plugin-svelte/src/rules/no-unnecessary-state-wrap.ts
new file mode 100644
index 0000000..6c1ff5e
--- /dev/null
+++ b/packages/eslint-plugin-svelte/src/rules/no-unnecessary-state-wrap.ts
@@ -0,0 +1,156 @@
+import { createRule } from '../utils/index.js';
+import { getSourceCode } from '../utils/compat.js';
+import { ReferenceTracker } from '@eslint-community/eslint-utils';
+import type { TSESTree } from '@typescript-eslint/types';
+
+const REACTIVE_CLASSES = [
+	'SvelteSet',
+	'SvelteMap',
+	'SvelteURL',
+	'SvelteURLSearchParams',
+	'SvelteDate',
+	'MediaQuery'
+];
+
+export default createRule('no-unnecessary-state-wrap', {
+	meta: {
+		docs: {
+			description: 'Disallow unnecessary $state wrapping of reactive classes',
+			category: 'Best Practices',
+			recommended: true
+		},
+		schema: [
+			{
+				type: 'object',
+				properties: {
+					additionalReactiveClasses: {
+						type: 'array',
+						items: {
+							type: 'string'
+						},
+						uniqueItems: true
+					},
+					allowReassign: {
+						type: 'boolean'
+					}
+				},
+				additionalProperties: false
+			}
+		],
+		messages: {
+			unnecessaryStateWrap: '{{className}} is already reactive, $state wrapping is unnecessary.',
+			suggestRemoveStateWrap: 'Remove unnecessary $state wrapping'
+		},
+		type: 'suggestion',
+		hasSuggestions: true,
+		conditions: [
+			{
+				svelteVersions: ['5'],
+				runes: [true, 'undetermined']
+			}
+		]
+	},
+	create(context) {
+		const options = context.options[0] ?? {};
+		const additionalReactiveClasses = options.additionalReactiveClasses ?? [];
+		const allowReassign = options.allowReassign ?? false;
+
+		const referenceTracker = new ReferenceTracker(getSourceCode(context).scopeManager.globalScope!);
+		const traceMap: Record<string, Record<string, boolean>> = {};
+		for (const reactiveClass of REACTIVE_CLASSES) {
+			traceMap[reactiveClass] = {
+				[ReferenceTracker.CALL]: true,
+				[ReferenceTracker.CONSTRUCT]: true
+			};
+		}
+
+		// Track all reactive class imports and their aliases
+		const references = referenceTracker.iterateEsmReferences({
+			'svelte/reactivity': {
+				[ReferenceTracker.ESM]: true,
+				...traceMap
+			}
+		});
+
+		const referenceNodeAndNames = Array.from(references).map(({ node, path }) => {
+			return {
+				node,
+				name: path[path.length - 1]
+			};
+		});
+
+		function isReassigned(identifier: TSESTree.Identifier): boolean {
+			const variable = getSourceCode(context).scopeManager.getDeclaredVariables(
+				identifier.parent
+			)[0];
+			return variable.references.some((ref) => {
+				return ref.isWrite() && ref.identifier !== identifier;
+			});
+		}
+
+		function reportUnnecessaryStateWrap(
+			stateNode: TSESTree.Node,
+			targetNode: TSESTree.Node,
+			className: string,
+			identifier?: TSESTree.Identifier
+		) {
+			if (allowReassign && identifier && isReassigned(identifier)) {
+				return;
+			}
+
+			context.report({
+				node: targetNode,
+				messageId: 'unnecessaryStateWrap',
+				data: {
+					className
+				},
+				suggest: [
+					{
+						messageId: 'suggestRemoveStateWrap',
+						fix(fixer) {
+							return fixer.replaceText(stateNode, getSourceCode(context).getText(targetNode));
+						}
+					}
+				]
+			});
+		}
+
+		return {
+			CallExpression(node: TSESTree.CallExpression) {
+				if (node.callee.type !== 'Identifier' || node.callee.name !== '$state') {
+					return;
+				}
+
+				for (const arg of node.arguments) {
+					if (
+						(arg.type === 'NewExpression' || arg.type === 'CallExpression') &&
+						arg.callee.type === 'Identifier'
+					) {
+						const name = arg.callee.name;
+						if (additionalReactiveClasses.includes(name)) {
+							const parent = node.parent;
+							if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
+								reportUnnecessaryStateWrap(node, arg, name, parent.id);
+							}
+						}
+					}
+				}
+			},
+
+			'Program:exit': () => {
+				for (const { node, name } of referenceNodeAndNames) {
+					if (
+						node.parent?.type === 'CallExpression' &&
+						node.parent.callee.type === 'Identifier' &&
+						node.parent.callee.name === '$state'
+					) {
+						const parent = node.parent.parent;
+						if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
+							reportUnnecessaryStateWrap(node.parent, node, name, parent.id);
+						}
+					}
+				}
+			}
+		};
+	}
+});
diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts
index 2242164..3151d17 100644
--- a/packages/eslint-plugin-svelte/src/utils/rules.ts
+++ b/packages/eslint-plugin-svelte/src/utils/rules.ts
@@ -50,6 +50,7 @@ import noSvelteInternal from '../rules/no-svelte-internal.js';
 import noTargetBlank from '../rules/no-target-blank.js';
 import noTrailingSpaces from '../rules/no-trailing-spaces.js';
 import noUnknownStyleDirectiveProperty from '../rules/no-unknown-style-directive-property.js';
+import noUnnecessaryStateWrap from '../rules/no-unnecessary-state-wrap.js';
 import noUnusedClassName from '../rules/no-unused-class-name.js';
 import noUnusedProps from '../rules/no-unused-props.js';
 import noUnusedSvelteIgnore from '../rules/no-unused-svelte-ignore.js';
@@ -124,6 +125,7 @@ export const rules = [
 	noTargetBlank,
 	noTrailingSpaces,
 	noUnknownStyleDirectiveProperty,
+	noUnnecessaryStateWrap,
 	noUnusedClassName,
 	noUnusedProps,
 	noUnusedSvelteIgnore,


Test patch:
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/_requirements.json
new file mode 100644
index 0000000..4986613
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/_requirements.json
@@ -0,0 +1,3 @@
+{
+	"svelte": ">=5.0.0"
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-config.json
new file mode 100644
index 0000000..8bde8d1
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-config.json
@@ -0,0 +1,7 @@
+{
+	"options": [
+		{
+			"additionalReactiveClasses": ["CustomReactiveClass1", "CustomReactiveClass2"]
+		}
+	]
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-errors.yaml
new file mode 100644
index 0000000..30518c2
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-errors.yaml
@@ -0,0 +1,34 @@
+- message: CustomReactiveClass1 is already reactive, $state wrapping is unnecessary.
+  line: 5
+  column: 25
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import { CustomReactiveClass1, CustomReactiveClass2 } from 'foo';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const custom1 = new CustomReactiveClass1();
+        	const custom2 = $state(new CustomReactiveClass2());
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        </script>
+- message: CustomReactiveClass2 is already reactive, $state wrapping is unnecessary.
+  line: 6
+  column: 25
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import { CustomReactiveClass1, CustomReactiveClass2 } from 'foo';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const custom1 = $state(new CustomReactiveClass1());
+        	const custom2 = new CustomReactiveClass2();
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        </script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-input.svelte
new file mode 100644
index 0000000..29aef19
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/additional-class-input.svelte
@@ -0,0 +1,10 @@
+<script>
+	import { CustomReactiveClass1, CustomReactiveClass2 } from 'foo';
+
+	// These should be reported as unnecessary $state wrapping
+	const custom1 = $state(new CustomReactiveClass1());
+	const custom2 = $state(new CustomReactiveClass2());
+
+	// Regular state usage is still valid
+	const regularState = $state(42);
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-config.json
new file mode 100644
index 0000000..e68fc5b
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-config.json
@@ -0,0 +1,7 @@
+{
+	"options": [
+		{
+			"allowReassign": true
+		}
+	]
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-errors.yaml
new file mode 100644
index 0000000..536b5f9
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-errors.yaml
@@ -0,0 +1,30 @@
+- message: SvelteSet is already reactive, $state wrapping is unnecessary.
+  line: 6
+  column: 21
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import { SvelteSet, SvelteMap } from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	// even with allowReassign: true because they are not reassigned
+        	const set = new SvelteSet();
+        	let map = $state(new SvelteMap());
+        </script>
+- message: SvelteMap is already reactive, $state wrapping is unnecessary.
+  line: 7
+  column: 19
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import { SvelteSet, SvelteMap } from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	// even with allowReassign: true because they are not reassigned
+        	const set = $state(new SvelteSet());
+        	let map = new SvelteMap();
+        </script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-input.svelte
new file mode 100644
index 0000000..85a1b15
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/allow-reassign-input.svelte
@@ -0,0 +1,8 @@
+<script>
+	import { SvelteSet, SvelteMap } from 'svelte/reactivity';
+
+	// These should be reported as unnecessary $state wrapping
+	// even with allowReassign: true because they are not reassigned
+	const set = $state(new SvelteSet());
+	let map = $state(new SvelteMap());
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/basic-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/basic-errors.yaml
new file mode 100644
index 0000000..4c2f5b9
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/basic-errors.yaml
@@ -0,0 +1,174 @@
+- message: SvelteSet is already reactive, $state wrapping is unnecessary.
+  line: 12
+  column: 21
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import {
+        		SvelteSet,
+        		SvelteMap,
+        		SvelteURL,
+        		SvelteURLSearchParams,
+        		SvelteDate,
+        		MediaQuery
+        	} from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = new SvelteSet();
+        	const map = $state(new SvelteMap());
+        	const url = $state(new SvelteURL('https://example.com'));
+        	const params = $state(new SvelteURLSearchParams('key=value'));
+        	const date = $state(new SvelteDate());
+        	const mediaQuery = $state(new MediaQuery('(min-width: 800px)'));
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        	const stateObject = $state({ foo: 'bar' });
+        </script>
+- message: SvelteMap is already reactive, $state wrapping is unnecessary.
+  line: 13
+  column: 21
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import {
+        		SvelteSet,
+        		SvelteMap,
+        		SvelteURL,
+        		SvelteURLSearchParams,
+        		SvelteDate,
+        		MediaQuery
+        	} from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = $state(new SvelteSet());
+        	const map = new SvelteMap();
+        	const url = $state(new SvelteURL('https://example.com'));
+        	const params = $state(new SvelteURLSearchParams('key=value'));
+        	const date = $state(new SvelteDate());
+        	const mediaQuery = $state(new MediaQuery('(min-width: 800px)'));
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        	const stateObject = $state({ foo: 'bar' });
+        </script>
+- message: SvelteURL is already reactive, $state wrapping is unnecessary.
+  line: 14
+  column: 21
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import {
+        		SvelteSet,
+        		SvelteMap,
+        		SvelteURL,
+        		SvelteURLSearchParams,
+        		SvelteDate,
+        		MediaQuery
+        	} from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = $state(new SvelteSet());
+        	const map = $state(new SvelteMap());
+        	const url = new SvelteURL('https://example.com');
+        	const params = $state(new SvelteURLSearchParams('key=value'));
+        	const date = $state(new SvelteDate());
+        	const mediaQuery = $state(new MediaQuery('(min-width: 800px)'));
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        	const stateObject = $state({ foo: 'bar' });
+        </script>
+- message: SvelteURLSearchParams is already reactive, $state wrapping is unnecessary.
+  line: 15
+  column: 24
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import {
+        		SvelteSet,
+        		SvelteMap,
+        		SvelteURL,
+        		SvelteURLSearchParams,
+        		SvelteDate,
+        		MediaQuery
+        	} from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = $state(new SvelteSet());
+        	const map = $state(new SvelteMap());
+        	const url = $state(new SvelteURL('https://example.com'));
+        	const params = new SvelteURLSearchParams('key=value');
+        	const date = $state(new SvelteDate());
+        	const mediaQuery = $state(new MediaQuery('(min-width: 800px)'));
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        	const stateObject = $state({ foo: 'bar' });
+        </script>
+- message: SvelteDate is already reactive, $state wrapping is unnecessary.
+  line: 16
+  column: 22
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import {
+        		SvelteSet,
+        		SvelteMap,
+        		SvelteURL,
+        		SvelteURLSearchParams,
+        		SvelteDate,
+        		MediaQuery
+        	} from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = $state(new SvelteSet());
+        	const map = $state(new SvelteMap());
+        	const url = $state(new SvelteURL('https://example.com'));
+        	const params = $state(new SvelteURLSearchParams('key=value'));
+        	const date = new SvelteDate();
+        	const mediaQuery = $state(new MediaQuery('(min-width: 800px)'));
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        	const stateObject = $state({ foo: 'bar' });
+        </script>
+- message: MediaQuery is already reactive, $state wrapping is unnecessary.
+  line: 17
+  column: 28
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: |
+        <script>
+        	import {
+        		SvelteSet,
+        		SvelteMap,
+        		SvelteURL,
+        		SvelteURLSearchParams,
+        		SvelteDate,
+        		MediaQuery
+        	} from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = $state(new SvelteSet());
+        	const map = $state(new SvelteMap());
+        	const url = $state(new SvelteURL('https://example.com'));
+        	const params = $state(new SvelteURLSearchParams('key=value'));
+        	const date = $state(new SvelteDate());
+        	const mediaQuery = new MediaQuery('(min-width: 800px)');
+
+        	// Regular state usage is still valid
+        	const regularState = $state(42);
+        	const stateObject = $state({ foo: 'bar' });
+        </script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/basic-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/basic-input.svelte
new file mode 100644
index 0000000..5af31b8
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/basic-input.svelte
@@ -0,0 +1,22 @@
+<script>
+	import {
+		SvelteSet,
+		SvelteMap,
+		SvelteURL,
+		SvelteURLSearchParams,
+		SvelteDate,
+		MediaQuery
+	} from 'svelte/reactivity';
+
+	// These should be reported as unnecessary $state wrapping
+	const set = $state(new SvelteSet());
+	const map = $state(new SvelteMap());
+	const url = $state(new SvelteURL('https://example.com'));
+	const params = $state(new SvelteURLSearchParams('key=value'));
+	const date = $state(new SvelteDate());
+	const mediaQuery = $state(new MediaQuery('(min-width: 800px)'));
+
+	// Regular state usage is still valid
+	const regularState = $state(42);
+	const stateObject = $state({ foo: 'bar' });
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/import-alias-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/import-alias-errors.yaml
new file mode 100644
index 0000000..ecde662
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/import-alias-errors.yaml
@@ -0,0 +1,28 @@
+- message: SvelteSet is already reactive, $state wrapping is unnecessary.
+  line: 5
+  column: 21
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: >
+        <script>
+        	import { SvelteSet as CustomSet, SvelteMap as CustomMap } from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = new CustomSet();
+        	const map = $state(new CustomMap());
+        </script>
+- message: SvelteMap is already reactive, $state wrapping is unnecessary.
+  line: 6
+  column: 21
+  suggestions:
+    - desc: Remove unnecessary $state wrapping
+      messageId: suggestRemoveStateWrap
+      output: >
+        <script>
+        	import { SvelteSet as CustomSet, SvelteMap as CustomMap } from 'svelte/reactivity';
+
+        	// These should be reported as unnecessary $state wrapping
+        	const set = $state(new CustomSet());
+        	const map = new CustomMap();
+        </script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/import-alias-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/import-alias-input.svelte
new file mode 100644
index 0000000..b9ff44b
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/invalid/import-alias-input.svelte
@@ -0,0 +1,7 @@
+<script>
+	import { SvelteSet as CustomSet, SvelteMap as CustomMap } from 'svelte/reactivity';
+
+	// These should be reported as unnecessary $state wrapping
+	const set = $state(new CustomSet());
+	const map = $state(new CustomMap());
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/_requirements.json
new file mode 100644
index 0000000..4986613
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/_requirements.json
@@ -0,0 +1,3 @@
+{
+	"svelte": ">=5.0.0"
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/additional-class-config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/additional-class-config.json
new file mode 100644
index 0000000..8bde8d1
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/additional-class-config.json
@@ -0,0 +1,7 @@
+{
+	"options": [
+		{
+			"additionalReactiveClasses": ["CustomReactiveClass1", "CustomReactiveClass2"]
+		}
+	]
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/additional-class-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/additional-class-input.svelte
new file mode 100644
index 0000000..ac036cb
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/additional-class-input.svelte
@@ -0,0 +1,9 @@
+<script>
+	import { CustomReactiveClass1, CustomReactiveClass2 } from 'foo';
+	// These should be allowed when not wrapped with $state
+	const custom1 = new CustomReactiveClass1();
+	const custom2 = new CustomReactiveClass2();
+
+	// Regular state usage is still valid
+	const regularState = $state(42);
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/allow-reassign-config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/allow-reassign-config.json
new file mode 100644
index 0000000..e68fc5b
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/allow-reassign-config.json
@@ -0,0 +1,7 @@
+{
+	"options": [
+		{
+			"allowReassign": true
+		}
+	]
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/allow-reassign-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/allow-reassign-input.svelte
new file mode 100644
index 0000000..0f88291
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/allow-reassign-input.svelte
@@ -0,0 +1,10 @@
+<script>
+	import { SvelteSet, SvelteMap } from 'svelte/reactivity';
+
+	// These should be allowed when allowReassign is true and variables are reassigned
+	let set = $state(new SvelteSet());
+	set = new SvelteSet([1, 2, 3]);
+
+	let map = $state(new SvelteMap());
+	map = new SvelteMap([['key', 'value']]);
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/basic-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/basic-input.svelte
new file mode 100644
index 0000000..77798ee
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unnecessary-state-wrap/valid/basic-input.svelte
@@ -0,0 +1,22 @@
+<script>
+	import {
+		SvelteSet,
+		SvelteMap,
+		SvelteURL,
+		SvelteURLSearchParams,
+		SvelteDate,
+		MediaQuery
+	} from 'svelte/reactivity';
+
+	// Valid usage of reactive classes without $state wrapping
+	const set = new SvelteSet();
+	const map = new SvelteMap();
+	const url = new SvelteURL('https://example.com');
+	const params = new SvelteURLSearchParams('key=value');
+	const date = new SvelteDate();
+	const mediaQuery = new MediaQuery('(min-width: 800px)');
+
+	// Regular state usage is still valid
+	const regularState = $state(42);
+	const stateObject = $state({ foo: 'bar' });
+</script>
diff --git a/packages/eslint-plugin-svelte/tests/src/rules/no-unnecessary-state-wrap.ts b/packages/eslint-plugin-svelte/tests/src/rules/no-unnecessary-state-wrap.ts
new file mode 100644
index 0000000..a4d8a39
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/src/rules/no-unnecessary-state-wrap.ts
@@ -0,0 +1,12 @@
+import { RuleTester } from '../../utils/eslint-compat.js';
+import rule from '../../../src/rules/no-unnecessary-state-wrap.js';
+import { loadTestCases } from '../../utils/utils.js';
+
+const tester = new RuleTester({
+	languageOptions: {
+		ecmaVersion: 2020,
+		sourceType: 'module'
+	}
+});
+
+tester.run('no-unnecessary-state-wrap', rule as any, loadTestCases('no-unnecessary-state-wrap'));