Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 066 / 100

State management patterns: practical tips and trade-offs

Organize state as apps grow.

Expona Admin7 min read

State management patterns: practical tips and trade-offs

As your Svelte application grows from a prototype into a multi-page product, the question of how to organize state becomes harder to ignore. Every team reaches a moment where passing props through five levels of components feels wrong, or where two different features need to react to the same data. State management patterns: practical tips and trade-offs matter because choosing the right strategy early prevents refactoring pain later. In this post, we'll walk through four foundational approaches—local state, shared modules, context, and server state—with real examples and the trade-offs that come with each.

Local state — $state inside components

The simplest state lives inside a single component using Svelte's $state rune. This is your starting point, and often your best choice when data belongs to one component only.

Consider a form that toggles between view and edit modes, tracks input values, and shows validation errors. None of this affects the rest of the app, so keeping it local is clean:

typescript
<script>
  let isEditing = $state(false);
  let formData = $state({
    name: 'Alice',
    email: 'alice@example.com',
    errors: {}
  });

  function toggleEdit() {
    isEditing = !isEditing;
  }

  function handleChange(field, value) {
    formData[field] = value;
    // Clear error on field change
    delete formData.errors[field];
  }

  function handleSubmit() {
    // Validation, API call, etc.
    isEditing = false;
  }
</script>

<button onclick={toggleEdit}>
  {isEditing ? 'Cancel' : 'Edit'}
</button>

{#if isEditing}
  <input
    value={formData.name}
    onchange={(e) => handleChange('name', e.target.value)}
  />
</if>

Trade-offs: Local state scales well within a single component and requires no ceremony. The downside emerges when sibling or parent components need access. At that point, you either pass it up and down via props (prop drilling) or move to a shared approach.

Shared modules — Reactive classes in .svelte.ts files

When state needs to be shared across multiple components but doesn't fit the request/response model, a shared reactive module works well. Create a class in a .svelte.ts file, export an instance, and use it from any component.

Imagine a shopping cart that multiple pages need to read and modify—product listings, a cart drawer, and a checkout page. A shared cart module centralizes the logic:

typescript
// src/lib/stores/cart.svelte.ts
export class Cart {
  items = $state([]);
  
  constructor() {
    this.loadFromStorage();
  }

  add(product) {
    const existing = this.items.find((item) => item.id === product.id);
    if (existing) {
      existing.quantity += 1;
    } else {
      this.items.push({ ...product, quantity: 1 });
    }
    this.saveToStorage();
  }

  remove(productId) {
    this.items = this.items.filter((item) => item.id !== productId);
    this.saveToStorage();
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  private loadFromStorage() {
    const stored = localStorage.getItem('cart');
    if (stored) {
      this.items = JSON.parse(stored);
    }
  }

  private saveToStorage() {
    localStorage.setItem('cart', JSON.stringify(this.items));
  }
}

export const cart = new Cart();

Now use it anywhere:

typescript
<script>
  import { cart } from '$lib/stores/cart.svelte.ts';
</script>

<p>Items in cart: {cart.items.length}</p>
<p>Total: ${cart.total.toFixed(2)}</p>

{#each cart.items as item (item.id)}
  <div>
    {item.name} x {item.quantity}
    <button onclick={() => cart.remove(item.id)}>Remove</button>
  </div>
{/each}

Trade-offs: Shared modules are excellent for client-side state that crosses component boundaries and doesn't need to sync with the server. They're easy to reason about and test. The main limitation is that they're singleton instances tied to the browser session—if you need per-user, persistent state, the server should own it. Also, shared modules don't automatically invalidate when data changes server-side, so stale state can creep in.

Context — Per-request or per-tree state

Svelte's context API provides a way to pass state down a component tree without prop drilling. It's scoped to a specific tree or route, making it ideal for temporary or hierarchical state.

A common example: a modal or dialog system. The modal state lives at a layout level and provides context to any descendant that needs to open or close it:

typescript
// src/lib/modalContext.svelte.ts
import { getContext, setContext } from 'svelte';

export class ModalManager {
  isOpen = $state(false);
  content = $state(null);

  open(content) {
    this.content = content;
    this.isOpen = true;
  }

  close() {
    this.isOpen = false;
    this.content = null;
  }
}

const MODAL_KEY = Symbol('modal');

export function setModalContext() {
  const manager = new ModalManager();
  setContext(MODAL_KEY, manager);
  return manager;
}

export function getModalContext() {
  return getContext(MODAL_KEY);
}

In a layout, set the context once:

typescript
<!-- src/routes/+layout.svelte -->
<script>
  import { setModalContext } from '$lib/modalContext.svelte.ts';
  
  const modal = setModalContext();
</script>

<main>
  <slot />
</main>

{#if modal.isOpen}
  <div class="modal">
    {@html modal.content}
    <button onclick={() => modal.close()}&>Close</button>
  </div>
{/if}

Any nested component can call getModalContext() and open the modal without passing props down:

typescript
<script>
  import { getModalContext } from '$lib/modalContext.svelte.ts';
  
  const modal = getModalContext();
</script>

<button
  onclick={() =>
    modal.open('<p>Are you sure?</p>')
  }
>
  Delete
</button>

Trade-offs: Context excels at avoiding prop drilling and works beautifully for tree-scoped state like theme, permissions, or modal state. In SvelteKit, context is recreated per request, so it's safe to use for request-specific data. The downside is that context is most useful when you have a clear parent-child hierarchy; it doesn't scale well for cross-cutting concerns that multiple unrelated trees need.

Server state — Load functions and invalidation

The most robust state is state owned by the server. SvelteKit's load functions and the invalidation API give you a structured way to fetch, cache, and refresh server data on the client.

Load functions run on the server and/or browser, and their return value becomes the page's data prop:

typescript
// src/routes/products/+page.server.ts
export async function load({ fetch }) {
  const res = await fetch('/api/products');
  const products = await res.json();
  
  return { products };
}

On the page, you receive it automatically:

typescript
<!-- src/routes/products/+page.svelte -->
<script>
  export let data;
</script>

{#each data.products as product (product.id)}
  <div>
    {product.name} - ${product.price}
  </div>
{/each}

When you need to refresh that data after a mutation (like adding an item to cart), use invalidate:

typescript
// src/lib/stores/cart.svelte.ts
import { invalidate } from '$app/navigation';

export class Cart {
  async add(product) {
    const res = await fetch('/api/cart/add', {
      method: 'POST',
      body: JSON.stringify({ productId: product.id })
    });
    
    if (res.ok) {
      // Refresh any load functions that depend on cart data
      await invalidate('route:/cart');
    }
  }
}

Trade-offs: Server-owned state is the most reliable approach for user data that needs to persist and sync across sessions. Load functions are cached intelligently, so you're not re-fetching on every navigation. The trade-off is latency—you can't update state instantly on the client without waiting for the server. For fast, local-only state (like toggling a UI element), this overhead isn't worth it. Server state shines for data that truly needs to be authoritative and shared across the user's sessions.

Common mistakes

  • Mixing concerns: Putting user data in a shared module instead of server state. This causes stale data and sync issues between tabs. Server state via load functions is simpler and more correct.
  • Over-using context: Treating context like a global store. Context is scoped to a tree—if you need state across unrelated pages, it won't work. Fall back to shared modules or server state.
  • Forgetting to invalidate: Making a mutation on the server but not calling invalidate, so the client still shows old data. Always invalidate after POST, PUT, or DELETE.
  • Local state in parents: Keeping component state in a parent "just in case" a sibling needs it. Keep it local until you're sure multiple components need it, then move it up or to a shared module.
  • Naive localStorage without TTL: Caching data in localStorage without an expiration or invalidation strategy leads to users seeing stale information across sessions.

Key takeaways

  • Start with local component state; move up only when needed.
  • Use shared reactive modules (.svelte.ts) for client-side, cross-component state like cart or theme.
  • Use context for tree-scoped state and to avoid prop drilling in hierarchies.
  • Let the server own persistent, user-specific data; fetch it in load functions and invalidate after mutations.
  • Choose the pattern that matches your state's scope and lifetime: local, shared, tree-scoped, or server-owned.
  • Invalidate early and often when you mutate server state to keep the client in sync.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles