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

correctness/effect-as-derived · Effect used to derive state

Use $derived instead of an $effect that only assigns state.

Severity: warning · Category: correctness

What it checks

Flags an $effect whose body only assigns to $state variables, in the component’s instance script.

Why it matters

Synchronising state with an $effect (the “useEffect → $effect” habit from React) runs after render and can trigger extra render passes or loops. A $derived value expresses the same dependency declaratively. Svelte recomputes it lazily, the next time it’s read, rather than scheduling a separate effect run.

How to fix

<script>
  let count = $state(0);
  // Instead of: $effect(() => { double = count * 2; });
  let double = $derived(count * 2);
</script>

Known limitation: mount-flag / hydration-guard effects

This check is structural, asking only “does the effect body only assign to $state?”, and not semantic, so it can’t distinguish a genuine derive-in-an-effect anti-pattern from the “mount signal” idiom used to avoid SSR/prerender ↔ hydration mismatches:

<script>
  let mounted = $state(false);
  $effect(() => {
    mounted = true;
  });
  // Must stay false during SSR/prerender and on the client's first render, or
  // hydration mismatches. $derived(canVibrate()) would evaluate eagerly during
  // hydration, reintroducing the exact flash this $effect exists to avoid.
  const showVibrationToggle = $derived(mounted && canVibrate());
</script>

$derived recomputes on next read, including during hydration; $effect runs only after the component has mounted to the DOM, which is the whole point here. Converting this shape to $derived reintroduces the bug the $effect was added to prevent, so suppress it with a svelte-vitals-disable-next-line comment rather than “fixing” it.

Known limitation: browser-global capture

The same structural blind spot also catches the documented fix for correctness/server-browser-global and correctness/instance-browser-global, an $effect that reads a browser-only global and assigns it to $state:

<script>
  let stored = $state(null);
  $effect(() => {
    stored = localStorage.getItem('filters'); // flagged here, but don't "fix" it
  });
</script>

$derived only evaluates its expression when something reads the derived value, but a value like this exists to be read (typically from the template), and a template read happens during SSR too. Converting this to $derived(localStorage.getItem('filters')) would reintroduce the exact ReferenceError: localStorage is not defined those two rules exist to prevent, the moment something reads it during server-side rendering. localStorage/window/etc. are exactly the values $derived cannot safely read, because there is no read that is guaranteed client-only. Prefer onMount, or svelte/reactivity/window for window properties, whose fix those two rules’ docs describe, and suppress this finding rather than switching to $derived.

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/effect-as-derived --> on the line above it, or turn the rule off:

export default {
  rules: {
    'correctness/effect-as-derived': 'off'
  }
};