- $effect Is Not componentDidMount The most misused rune in Svelte 5, and how to know when you actually need it (rarely).
- Routes Are State Boundaries File-based routing isn't just organization—it's architecture. Each route owns its data, and that's a feature.
- No Global Store Until Proven Guilty Most apps don't need global state. Learn when route-level state is enough, and when a store actually earns its place.
Good Ideas Gone Wrong
Every pattern here seemed like a good idea at the time.
Someone wrote it to solve a problem. It worked. Then the codebase grew, requirements changed, and the “solution” became the problem.
These are the patterns that cause the most pain in real-world codebases. Learn to recognize them. Avoid them before they hurt you.
1. Using $effect for Data Fetching
The Pattern:
<script>
let { userId } = $props();
let user = $state(null);
let loading = $state(true);
$effect(() => {
loading = true;
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
user = data;
loading = false;
});
});
</script>
{#if loading}
<p>Loading...</p>
{:else}
<h1>{user.name}</h1>
{/if} Why It Seems Smart:
- It’s reactive! When
userIdchanges, data refetches automatically. - It’s all in one component. Easy to understand.
- It works.
Why It Hurts:
- No SSR. Effects only run on the client. Your page loads empty, then flickers with content. Bad for SEO, bad for perceived performance.
- Race conditions. If
userIdchanges quickly, responses arrive out of order. You might show stale data. - No error handling. Where does the error state live? You need another
$statevariable and try/catch. - Duplicate logic. Every component that needs user data reimplements fetching.
What to Do Instead:
Use load functions:
// +page.server.ts
export async function load({ params }) {
const user = await db.getUser(params.userId);
return { user };
} <!-- +page.svelte -->
<script>
let { data } = $props();
</script>
<h1>{data.user.name}</h1> Load functions handle SSR, errors, and loading states. Data is available when the component renders.
2. Derived State That Mutates Other State
The Pattern:
<script>
let items = $state([1, 2, 3, 4, 5]);
let total = $state(0);
// "Derive" total by mutating in an effect
$effect(() => {
total = items.reduce((a, b) => a + b, 0);
});
</script> Why It Seems Smart:
- It keeps
totalin sync withitems. - You can use
totallike any other state.
Why It Hurts:
- Unnecessary indirection. You have two pieces of state when you need one.
- Timing issues. The effect runs after render. There’s a moment when
totalis stale. - Harder to reason about. Where does
totalcome from? You have to find the effect that sets it.
What to Do Instead:
Use $derived:
<script>
let items = $state([1, 2, 3, 4, 5]);
let total = $derived(items.reduce((a, b) => a + b, 0));
</script> One piece of state (items), one derived value (total). Always in sync. No timing issues.
3. Global Store for Route-Specific Data
The Pattern:
// lib/stores/project.js
export const currentProject = writable(null);
export const projectTasks = writable([]);
export const projectMembers = writable([]); <!-- routes/projects/[id]/+page.svelte -->
<script>
import { currentProject, projectTasks } from '$lib/stores/project';
import { page } from '$app/stores';
$effect(() => {
// Load project when route changes
fetch(`/api/projects/${$page.params.id}`)
.then(r => r.json())
.then(p => currentProject.set(p));
fetch(`/api/projects/${$page.params.id}/tasks`)
.then(r => r.json())
.then(t => projectTasks.set(t));
});
</script> Why It Seems Smart:
- Any component can access project data.
- No prop drilling needed.
- Feels organized—all project state in one place.
Why It Hurts:
- Stale data. Navigate to a different project, and the old data is still there until the effect runs.
- Unclear ownership. Who’s responsible for clearing this when navigating away? What if they forget?
- Race conditions. Fast navigation means fetches overlap. You might show project A’s tasks under project B.
- Testing nightmare. Every test needs to set up the store.
What to Do Instead:
Let the route own its data:
// routes/projects/[id]/+layout.server.ts
export async function load({ params }) {
const project = await db.getProject(params.id);
const tasks = await db.getProjectTasks(params.id);
return { project, tasks };
} <!-- routes/projects/[id]/+layout.svelte -->
<script>
let { data, children } = $props();
</script>
<h1>{data.project.name}</h1>
{@render children()} Every page under /projects/[id]/ gets fresh, route-specific data. No global store needed.
4. Over-Abstracting Before Duplication
The Pattern:
<!-- GenericDataTable.svelte -->
<script>
let {
data,
columns,
sortable = true,
filterable = true,
pageable = true,
pageSize = 10,
onRowClick,
onSort,
onFilter,
rowTemplate,
headerTemplate,
footerTemplate,
emptyTemplate,
loadingTemplate,
// ... 20 more props
} = $props();
</script>
<!-- 300 lines of generic table logic --> Built before any actual table was needed. “We’ll need tables everywhere!”
Why It Seems Smart:
- DRY! Write once, use everywhere.
- Handles every case.
- Feels like “real engineering.”
Why It Hurts:
- Complexity tax. Every table use pays for features it doesn’t need.
- Rigid. That one table that needs something slightly different? Fight the abstraction or fork it.
- Hard to change. Modify the generic component, test everywhere it’s used.
- Premature. You don’t know what tables you need until you build them.
What to Do Instead:
Build specific components first:
<!-- UserTable.svelte -->
<script>
let { users } = $props();
</script>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
{/each}
</tbody>
</table> When you’ve built three tables and see clear patterns, then extract the common parts. Abstraction should emerge from duplication, not precede it.
5. Syncing State Between Components Instead of Lifting
The Pattern:
<!-- Sidebar.svelte -->
<script>
import { selectedId } from '$lib/stores/selection';
</script>
<!-- MainContent.svelte -->
<script>
import { selectedId } from '$lib/stores/selection';
$effect(() => {
if ($selectedId) {
loadDetails($selectedId);
}
});
</script> Two sibling components share a store to communicate.
Why It Seems Smart:
- Siblings can’t pass props to each other.
- The store “connects” them.
- It works.
Why It Hurts:
- Hidden coupling. Looking at either component alone, you don’t see the relationship.
- Who owns the state? Both components can change it. Neither owns it.
- Hard to test. Need to set up the store for either component.
What to Do Instead:
Lift state to the common parent:
<!-- +page.svelte -->
<script>
let selectedId = $state(null);
</script>
<div class="layout">
<Sidebar
{selectedId}
onselect={(id) => selectedId = id}
/>
<MainContent {selectedId} />
</div> The parent owns the state. Children receive it as props. The relationship is explicit in the parent’s code.
The Common Thread
All these anti-patterns share a trait: they hide relationships.
- Effects hide the connection between data and state.
- Global stores hide who owns what.
- Premature abstraction hides what you actually need.
- Store-synced siblings hide their coordination.
Calm code makes relationships visible. When you read a component, you can see:
- Where its data comes from (props, load functions)
- What it depends on (imports, props)
- What it affects (events, callbacks)
If understanding a component requires checking multiple other files, something’s wrong.
The Antidote
When you’re about to reach for one of these patterns, ask:
- Am I hiding something? If so, can I make it explicit?
- Am I solving a problem I don’t have yet? If so, wait until I have it.
- Am I creating coupling? If so, can I remove it by restructuring?
- Will someone reading this code understand it? If not, simplify.
Simple code isn’t always easy to write. But it’s always easier to maintain.
Next up: We’ve covered the patterns, the anti-patterns, and the philosophy. Now let’s put it all together with a complete application that demonstrates everything in action.