Skip to content
svelte-vitals
Esc
↑↓navigate↵open⌘Jpreview
On this page

correctness/unmutated-state · Unmutated $state

Use const (or $state.raw) for a $state that is never mutated.

Severity: info · Category: correctness

What it checks

Flags a let x = $state(...) whose value is never written or escaped anywhere in the component: not reassigned, not mutated (x.a = …, x.push()), not bound (bind:value={x}), not passed to a function or component, not handed out by a getter (get x() { return x; }) unless it holds a primitive.

State passed to a use:/transition:/animate: directive is not flagged either, since the receiving code holds the reference and may mutate it invisibly. Neither is state a function returns (return live), or a state placed in an object or array (setContext('form', { lang }), const payload = { ids }) when its initial value or type argument shows it holds an object ($state({}), $state([]), $state(new Date()), $state(saved ?? {}), $state<string[]>(…), or a built-in object type such as $state<Record<string, string>>(…); an app’s own type name may stand for a primitive and is not read), for the same reason. Only the state itself counts: { count: state.count } places a property, not the state. A spread copies the state ([...list, x]) and does not count, and neither does a state that may hold a primitive, which an object holds a copy of.

Why it matters

A $state that is never mutated pays for reactivity it never uses, meaning deep proxying and dependency tracking. const is clearer and cheaper; $state.raw fits when you only ever reassign the value wholesale (never mutate its properties).

How to fix

<script>
  // Instead of: let title = $state('Dashboard');
  const title = 'Dashboard';

  // Reassigned wholesale but never deeply mutated? Use $state.raw:
  let data = $state.raw(initial);
  data = nextValue;
</script>

Mode differences

None. This rule reads source, the same .svelte and .ts files, everywhere it runs. The CLI, the Vite plugin’s build pass, and the live dashboard’s static baseline all report it identically, and the rendered-HTML pass never re-evaluates it. Scoping a run with --route skips it: component-scoped rules have no route to attribute a finding to.

Disabling

Silence a single occurrence with <!-- svelte-vitals-disable-next-line correctness/unmutated-state --> on the line above it, or turn the rule off:

export default {
  rules: {
    'correctness/unmutated-state': 'off'
  }
};