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

correctness/prop-mutation · Mutated non-bindable prop

Don't mutate a prop from $props() unless it is declared $bindable.

Severity: warning · Category: correctness

What it checks

Flags a mutation of a value destructured from $props() that is not declared $bindable: a member write (user.name = …, obj.count += 1), delete obj.x, or a mutating method call (items.push(…), arr.splice(…), map.set(…), …). A ...rest binding is tracked too, since rest props can never be individually declared $bindable.

Plain reassignment of the prop itself (count = 5) is not flagged: Svelte’s docs explicitly sanction temporary reassignment for unsaved ephemeral state. Only mutation is prohibited.

A local reusing the prop’s name shadows it and is not the prop at all, so mutating it is not flagged: a function or arrow parameter, a block-scoped let/const redeclaration, a for/for-of/for-in loop variable, a catch parameter, or a {#each ... as x} variable.

{#snippet}/{:then}/{:catch} bindings are not tracked and could still produce a false positive. This is a deliberately partial mitigation, not full scope resolution.

Why it matters

Svelte’s docs say plainly: “don’t mutate props” unless they are $bindable. Three failure modes, none caught by the compiler:

  • A plain-object prop mutation is a silent no-op: the object isn’t a state proxy, so not even the dev-time warning fires.
  • A reactive-state-proxy prop mutation works, but triggers the ownership_invalid_mutation dev warning, and only if that code path is actually exercised at runtime.
  • A fallback value in use behaves like a plain object, so mutation has no effect.

Static analysis catches all three at review/CI time, before the code path has to run.

How to fix

<script>
  let { user } = $props();

  // Instead of mutating the prop directly:
  function rename(name) {
    user.name = name; // no-op or ownership_invalid_mutation warning
  }

  // Clone before mutating:
  function rename(name) {
    const next = { ...user, name };
    // ...use `next`, or lift the change to the parent
  }

  // Or make it bindable, if the parent and child should share it:
  let { user = $bindable() } = $props();
</script>

Legacy mode (export let)

The same class of bug exists in legacy-mode components, for a different reason. Svelte’s legacy reactivity is assignment-based, so a mutating method call never triggers an update on its own, even when the prop is passed with bind::

<script>
  export let items;

  // flagged — the mutation itself doesn't trigger an update
  function addItem(item) {
    items.push(item);
  }
</script>

Reassign the prop after mutating it to re-trigger reactivity. This is Svelte’s own documented pattern, not a workaround:

<script>
  export let items;

  function addItem(item) {
    items.push(item);
    items = items; // tells the compiler `items` changed
  }
</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/prop-mutation --> on the line above it, or turn the rule off:

export default {
  rules: {
    'correctness/prop-mutation': 'off'
  }
};