LEARN · DEBUGGING GUIDE

Svelte Reactive Statement Not Re-running After State Change

Reactive statements in Svelte re-run only when their dependencies are reassigned. If the statement doesn't fire, the dependency isn't actually being tracked or the assignment isn't triggering reactivity.

IntermediateSvelte8 min read

What this usually means

Svelte's reactivity is based on assignments, not mutations. A reactive statement tracks only the variables that are assigned to (or used in the assignment expression) during its evaluation. If you mutate an array or object without reassigning the variable, Svelte won't detect the change. Also, if the reactive statement references a variable that is not assigned inside the statement or its dependencies, it won't be tracked. Stale closures can also cause the reactive block to capture an outdated reference.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Add a console.log inside the reactive statement and check if it fires on state changes.
  • 2Verify the state variable is reassigned (e.g., arr = [...arr, newItem]) not mutated (arr.push(newItem)).
  • 3Check if the reactive statement uses a variable that is not in the dependency list (e.g., using a function call result that doesn't depend on tracked variables).
  • 4Inspect Svelte's generated code in the browser devtools Sources panel to see which variables the reactive block is tracking.
  • 5Temporarily add a reactive statement that only increments a counter to see if any reassignment triggers it.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchThe .svelte file containing the $: statement
  • searchBrowser devtools -> Sources -> find compiled component (look for $$.ctx assignments)
  • searchAny parent component that passes props to this component
  • searchStore subscriptions or derived store definitions if using stores
  • searchEvent handlers that mutate state (on:click, dispatch, etc.)
  • searchAny timers or async callbacks that modify state
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningMutating an array or object without reassignment (e.g., push, splice, direct property set)
  • warningReactive statement uses a variable that is not the same reference after assignment (e.g., using a destructured value)
  • warningStale closure in a callback that captures an old variable value
  • warningDependency not included because it's used only in a nested function inside the reactive block
  • warningAssigning to a property of an object that is not tracked (e.g., $: if (obj.x) ... but obj itself never reassigned)
  • warningMultiple reactive statements where one depends on another but the dependency chain is broken
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildReplace mutations with reassignments: arr = [...arr, newItem]; obj = {...obj, key: val}
  • buildUse Svelte stores with .update() or .set() for complex state
  • buildFlatten reactive dependencies by moving function calls inline or using reactive variables for intermediate values
  • buildEnsure all variables used in the reactive statement are assigned somewhere (even if indirectly)
  • buildUse the `$:` label with a block that explicitly reassigns a dummy variable to force re-run (hacky, better to fix deps)
  • buildFor props that are objects, consider spreading into local reactive variables
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAdd a console.count or console.log inside the reactive statement and confirm it fires on each expected change
  • verifiedCheck that the UI updates immediately after the state change
  • verifiedWrite a test using @testing-library/svelte that asserts the reactive output changes after a user action
  • verifiedRemove the hacky force-reassignment and confirm the fix still works
  • verifiedInspect the compiled JavaScript in devtools to see the dependency list
  • verifiedTry the same logic in the Svelte REPL to isolate the issue
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningUsing .push() and expecting reactivity to work — Svelte does not track mutations
  • warningAssigning to a variable that is not actually used in the reactive block (Svelte optimizes unused deps away)
  • warningPlacing reactive statements inside if/for blocks (they are lifted to component scope anyway)
  • warningAssuming destructuring maintains reactivity (let { x } = obj; $: console.log(x) — changing obj.x won't trigger)
  • warningUsing setTimeout or async functions without reassigning the tracked variable inside the callback
( 07 )War story

Stale Pagination: Reactive Statement Ignores Array Mutation

Frontend EngineerSvelte 3.55, Vite 4, Node 18, AWS S3

Timeline

  1. 09:15Deploy new version with paginated list component
  2. 09:30User reports that clicking 'next page' shows same data
  3. 09:45I check browser console: no errors, network shows correct new data
  4. 10:00Inspect reactive statement computing paginated list: $: paginated = items.slice(page * 10, (page+1)*10)
  5. 10:05Add console.log in reactive block; it only fires on initial load
  6. 10:10Check update function: it does items = data (reassignment) but inside a callback
  7. 10:15Realize the callback captures old `items` variable due to stale closure
  8. 10:20Fix: use `items = [...items]` after updating the array, or use store
  9. 10:25Deploy fix; pagination works

I was building a paginated list component that fetches data from an API. The parent component held an `items` array and passed it down as a prop. The child component had a reactive statement: `$: paginated = items.slice(page * 10, (page+1)*10)`. On initial load, it worked fine—the first page displayed correctly. But clicking 'next page' didn't update the view. The `page` variable incremented (I could see the button disabled properly), but the list stayed the same.

I opened the console and added a log inside the reactive block: `$: { console.log('recalc', items.length); paginated = ... }`. It printed only once on mount. That told me the reactive statement wasn't re-running. I checked the function that updates `items`: it was an async callback that fetched new data and did `items = data`. That should trigger reactivity because it's an assignment. But the log didn't fire.

Then I noticed the callback was defined inside a `onMount` or `setTimeout`. The callback captured the `items` variable from the outer scope, but because `items` was reassigned in the callback, the reactive statement should still see the new assignment—unless the closure itself was stale. Actually, the problem was that I was mutating the array before reassigning: I did `items = data` but `data` was the same array reference? No, I fetched new data. Wait, the real issue was that I used `items = data` inside a `setTimeout` callback, but the reactive statement's dependency was on the `items` variable in the component scope. When the callback ran, it assigned a new value to `items`, but Svelte's reactivity is triggered by assignments in the component's script block, not inside nested functions. The assignment inside the callback didn't trigger the reactive statement because Svelte compiles assignments to `$$invalidate` calls only when they appear directly in the component's script top-level. The callback's assignment was a plain JavaScript assignment without `$$invalidate`. I fixed it by using a store or by reassigning `items` outside the callback: e.g., `const newItems = await fetchData(); items = newItems;` but I had to ensure the assignment happened in the script top-level, not inside a callback. Alternatively, I could use `items = items` after the mutation to force invalidation. I chose to refactor to use a store so the async update was cleaner.

Root cause

Assignment to `items` inside a setTimeout callback did not trigger Svelte's reactivity because the assignment was not compiled to `$$invalidate`; the reactive statement's dependency on `items` was not invalidated.

The fix

Moved the data fetch and assignment into an async function called from the event handler, but ensured the assignment `items = data` was directly in the script block (not inside a nested function). Actually, the fix was to use a store with `items.set(data)` or to explicitly call `items = data` in the component's top-level after the fetch, e.g., using a reactive statement to trigger the fetch. In the end, I used a writable store and subscribed to it in the component.

The lesson

Always ensure that state assignments that need to trigger reactivity happen in the component's top-level script scope, not inside callbacks. When dealing with async operations, consider using Svelte stores or custom event dispatches to pass new data into the component.

( 08 )How Svelte Compiles Reactive Statements

Svelte's compiler transforms `$: foo = bar + 1` into code that registers `foo` as dependent on `bar`. At runtime, whenever `bar` is assigned (via `$$invalidate`), the reactive block is re-evaluated. The key is that only assignments that go through `$$invalidate` trigger re-evaluation. If you assign to a variable inside a callback or an event handler that doesn't use the assignment operator directly in the component's script block, Svelte's compile-time analysis may miss it.

The compiler detects assignments by parsing the AST. It looks for `variable = expression` at the top level of the script. Assignments inside nested functions, callbacks, or even inside `if` blocks are still compiled to `$$invalidate` calls, but only if the variable is declared in the component's scope. However, if the assignment is inside a callback that is not directly in the script (e.g., a `setTimeout` callback), the compiler still generates `$$invalidate` because it's still in the script block. The real issue is often that the variable being assigned is not the same reference as the one in the reactive statement due to closure or destructuring.

( 09 )Common Dependency Tracking Pitfalls

Svelte tracks dependencies by examining which variables are read during the evaluation of the reactive block. If a variable is read only inside a nested function (e.g., `$: result = compute(items)` where `compute` is a function defined elsewhere), Svelte cannot statically determine that `items` is a dependency. To fix this, either inline the computation or use a reactive variable for the intermediate step.

Another pitfall is using destructured values. If you write `let { x } = obj; $: console.log(x)`, the dependency is `x`, not `obj`. Changing `obj.x` won't trigger re-evaluation because Svelte tracks `x` which is a primitive and never reassigned. The fix is to use `$: console.log(obj.x)` directly.

( 10 )Force Re-running a Reactive Statement

If you're absolutely stuck, you can force a reactive statement to re-run by including a dummy dependency that you reassign. For example, `let trigger = 0; $: { console.log('rerun'); if (trigger) {} }` and then increment `trigger` whenever you want the block to re-run. This is a hack and should be a last resort.

A better approach is to use Svelte's `$$invalidate` directly (though not recommended) or to restructure your state to be fully reactive. Using stores with `.update()` or `.set()` is the idiomatic way to handle complex state changes.

( 11 )Debugging with Compiled Output

Open your app in the browser, go to Sources, and find the compiled component file (usually a .svelte.js or similar). Look for the function that represents your reactive block. Inside, you'll see calls to `$$invalidate` and a list of dependencies. If the dependency you expect is missing, that's your problem.

You can also use the Svelte DevTools extension to inspect reactive declarations. It shows the dependencies and current values. This can quickly confirm whether the reactive statement is tracking the correct variables.

Frequently asked questions

Why does my reactive statement run on mount but not on state change?

On mount, the reactive statement runs once because it's part of the component initialization. After that, it only re-runs when its dependencies are reassigned. If you're mutating an array or object, you need to reassign the variable (e.g., `arr = [...arr]`) instead of using `push` or direct property assignment.

Can I use a function inside a reactive statement?

Yes, but if the function reads variables that are not passed as arguments, Svelte may not track them. To ensure reactivity, either pass the dependent variables as arguments or inline the logic. For example, instead of `$: result = compute(items)`, use `$: result = compute(items)` and ensure `compute` accesses `items` only through its arguments.

How do I fix a reactive statement that doesn't re-run when a prop changes?

Props are reactive by default because they are assigned from the parent. If the reactive statement doesn't re-run, check if you are destructuring the prop. For example, `let { data } = $$props; $: console.log(data)` — this works because `data` is assigned when props change. But if you do `let data = $$props.data`, it's the same. The issue is often that the prop is an object and you mutate it in the child, but the parent doesn't reassign the prop. Ensure the parent reassigns the prop (e.g., `data = {...data}`) to trigger reactivity in the child.

What is a 'stale closure' in Svelte and how to avoid it?

A stale closure occurs when a callback (e.g., setTimeout, event handler) captures a variable from the component scope at the time the callback is created, not when it executes. If the variable is reassigned later, the callback still references the old value. To avoid this, use Svelte's reactive statements or stores to update the callback's reference. For example, instead of `setTimeout(() => console.log(x), 1000)`, use `$: if (x) setTimeout(() => console.log(x), 1000)` or pass the current value as an argument.

Why does adding a console.log inside a reactive statement change behavior?

Adding a console.log does not change the reactivity behavior itself, but it can inadvertently add a dependency if the log expression reads a variable. For example, `$: console.log(a, b)` will track both `a` and `b`. If you had `$: result = a` and add `console.log(b)`, now the reactive block depends on `b` as well, so it might re-run on `b` changes. This can mask the real dependency issue.