Skip to content
svelte-vitals
English
Esc
navigateopen⌘Jpreview
On this page

correctness/nonreactive-builtin-state · Non-reactive built-in in $state

A plain Map, Set, Date, URL, or URLSearchParams in $state is not proxied, so its mutations are invisible to reactivity and the UI silently stops updating.

Severity: warning · Category: correctness

What it checks

Flags a top-level $state binding constructed from a plain built-in (Map, Set, Date, URL, or URLSearchParams) when a mutation of that instance is observed inside a function or template handler:

<script>
  let tags = $state(new Set());

  function toggle(tag) {
    tags.add(tag); // flagged — this mutation is not tracked
  }
</script>

{#each [...tags] as tag}<span>{tag}</span>{/each}

Detection is deliberately conservative:

  • Only type-specific mutating operations count (map.set, set.add, date.setHours, params.append, url.href = …, url.searchParams.set(…), …); read methods never do.
  • A binding reassigned after mutation (tags = new Set(tags)) works correctly and is exempt. The bare self-assignment tags = tags is a no-op in Svelte 5 and is not.
  • Mutations at script top level run once before the first render, so they are exempt too.

Why it matters

$state’s deep proxy covers plain objects and arrays only. A built-in instance keeps working as data, and every set/add/append succeeds, but reactivity never hears about it: effects don’t rerun, deriveds don’t recompute, the template keeps the old contents.

The component renders correctly once, then silently stops updating, with no compiler or svelte-check warning. Svelte ships svelte/reactivity precisely for this.

How to fix

<script>
  import { SvelteSet } from 'svelte/reactivity';

  let tags = new SvelteSet();
</script>

SvelteMap, SvelteSet, SvelteDate, SvelteURL, and SvelteURLSearchParams are drop-in replacements with identical APIs. Alternatively, keep the plain built-in and reassign a fresh instance after each change (tags = new Set(tags)). The rule recognizes that pattern and stays quiet.

Limitations

Out of reach or out of scope:

  • Mutations outside the component, such as an instance passed to a helper, store or child that mutates it. The rule counts only what it can see, so escape-only usage is never flagged.
  • A local class shadowing a built-in name (class Map { … }) would be misattributed; shadowing global built-ins is its own problem.
  • Runes-module (.svelte.ts) and class-field $state, in this version.

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

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