The Wrong Question

“When does this code run?”

That’s the question lifecycle thinking trains you to ask. On mount? On update? Before render? After render? On unmount?

It’s the wrong question.

The right question is: “What does this code depend on?”

This shift — from when to what — is the single most important mental model change when moving to Svelte 5. Get it right, and entire categories of bugs simply don’t happen. Miss it, and you’ll fight timing issues forever.

The Lifecycle Trap

In lifecycle-based frameworks, you think in terms of moments:

  • Mount: The component appears. Set up subscriptions. Fetch initial data. Initialize third-party libraries.
  • Update: Props or state changed. Compare old and new values. Decide what to re-fetch. Sync derived values.
  • Unmount: The component disappears. Clean up subscriptions. Cancel pending requests. Dispose of resources.

This model maps to how browsers work. DOM nodes get created, updated, and removed. Events happen at specific moments. It feels natural.

But it creates problems.

Problem 1: You have to know when things change.

If you fetch data on mount, what happens when the user ID prop changes? You need to also fetch on update—but only when that specific prop changes. You end up comparing previous props to current props, tracking what changed, and deciding what to do.

// Pseudocode for lifecycle thinking
onMount(() => {
  fetchUser(userId);
});

onUpdate((prevProps) => {
  if (prevProps.userId !== userId) {
    fetchUser(userId);
  }
});

This is two places where the same logic lives. Two places to update when requirements change. Two places where bugs can hide.

Problem 2: You have to manage timing manually.

What if the user changes quickly, triggering multiple fetches? The responses might arrive out of order. Now you need to track which request is “current” and ignore stale responses.

// More lifecycle complexity
let currentRequestId = 0;

function fetchUser(id) {
  const requestId = ++currentRequestId;
  api.getUser(id).then(user => {
    if (requestId === currentRequestId) {
      setUser(user);
    }
  });
}

The actual business logic—“show the user’s data”—is buried under timing management.

Problem 3: Dependencies are implicit.

Nothing in the code declares that the fetch depends on userId. You, the developer, have to remember this relationship. When someone adds a new dependency later, they have to find all the places that need updating.

Lifecycle thinking asks you to be a human dependency tracker. Humans are bad at this.

The Data Flow Alternative

Svelte 5 inverts the model. Instead of “when does this run?”, you declare “what does this depend on?” The framework figures out the timing.

<script>
  let { userId } = $props();
  
  let user = $state(null);
  
  $effect(() => {
    // This runs whenever userId changes
    // Svelte knows because it sees you reading userId
    api.getUser(userId).then(u => user = u);
  });
</script>

There’s no onMount. No onUpdate. No comparison of previous and current values. You declared that user depends on userId, and Svelte handles the rest.

When userId changes, the effect re-runs. Automatically. You don’t have to remember to handle updates—updates are just another run.

This is data flow thinking: values flow through dependencies, and the UI reflects the current state of those values.

A Real Example: Filtered List

Let’s make this concrete. You have a list of items and a search filter. The displayed items should update when either changes.

Lifecycle approach:

// Pseudocode - don't do this
let items = [];
let filter = '';
let filteredItems = [];

onMount(() => {
  filteredItems = filterItems(items, filter);
});

onUpdate((prev) => {
  if (prev.items !== items || prev.filter !== filter) {
    filteredItems = filterItems(items, filter);
  }
});

function filterItems(items, filter) {
  return items.filter(item => 
    item.name.toLowerCase().includes(filter.toLowerCase())
  );
}

Three pieces of state. Two lifecycle hooks. Manual change detection. The relationship between filteredItems, items, and filter is implicit.

Data flow approach:

<script>
  let items = $state([
    { name: 'Apple' },
    { name: 'Banana' },
    { name: 'Cherry' }
  ]);
  
  let filter = $state('');
  
  let filteredItems = $derived(
    items.filter(item => 
      item.name.toLowerCase().includes(filter.toLowerCase())
    )
  );
</script>

<input bind:value={filter} placeholder="Search..." />

<ul>
  {#each filteredItems as item}
    <li>{item.name}</li>
  {/each}
</ul>

Two pieces of source state. One derived value. The dependency is declared right there: filteredItems depends on items and filter.

No lifecycle hooks. No manual synchronization. No way to forget updating filteredItems when filter changes—the dependency is the code.

Why This Prevents Bugs

Data flow thinking prevents bugs because you can’t forget dependencies.

In lifecycle code, nothing enforces that your onUpdate handler checks all the right values. You might check userId but forget includeArchived. The bug appears only when someone changes includeArchived without changing userId. These bugs are hard to reproduce and harder to find.

In data flow code, dependencies are automatic. Svelte sees what you read. If you read userId and includeArchived, both become dependencies. You can’t forget because you don’t declare them—Svelte discovers them.

<script>
  let { userId, includeArchived } = $props();
  
  // Both userId and includeArchived are automatic dependencies
  // Change either one, and this effect re-runs
  $effect(() => {
    api.getItems(userId, { includeArchived }).then(data => {
      items = data;
    });
  });
</script>

Data flow thinking also prevents stale closure bugs.

In lifecycle code with hooks, you often capture values at a moment in time. If the value changes before your async operation completes, you’re working with stale data.

// Stale closure bug
function handleClick() {
  const currentCount = count; // Captured at click time
  setTimeout(() => {
    console.log(currentCount); // Still the old value
  }, 1000);
}

In Svelte, reactive state is always current. When you read count, you get the current value, not a captured snapshot.

When You Still Need Lifecycle

Data flow doesn’t replace all lifecycle thinking. Some things genuinely happen at specific moments:

  • Setting up non-reactive integrations: Third-party libraries that need a DOM node require the node to exist first.
  • Cleanup: When a component unmounts, subscriptions need canceling.
  • One-time initialization: Some setup shouldn’t re-run when dependencies change.

Svelte handles these with $effect:

<script>
  let canvas;
  
  $effect(() => {
    // Runs after the component mounts
    const ctx = canvas.getContext('2d');
    drawChart(ctx, data);
    
    // Cleanup runs before re-running or on unmount
    return () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    };
  });
</script>

<canvas bind:this={canvas}></canvas>

The difference: even this lifecycle-ish code still uses data flow. The effect re-runs when data changes because Svelte sees the dependency. You get lifecycle behavior and automatic dependency tracking.

The Mental Shift

Stop asking: “When should this code run?”

Start asking: “What values does this code depend on?”

For computed values, use $derived. For side effects, use $effect. In both cases, Svelte handles the “when.” You declare the “what.”

This shift takes practice. If you’re coming from React or Vue, you’ve trained yourself to think in lifecycle terms. That training will fight you. But once the data flow model clicks, you’ll wonder how you ever lived without it.

Your code becomes a declaration of relationships:

  • filteredItems depends on items and filter
  • fullName depends on firstName and lastName
  • isValid depends on email and password

The framework turns those declarations into running code. The timing, the updates, the synchronization—all handled. You think about data. Svelte thinks about when.


Next up: If mutation is safe in Svelte, why did we spend years avoiding it? We’ll tackle the mutation question and discover that the real enemy was never mutation itself.