DEV Community

Cover image for Mastering Context and Async Data in Svelte (with Examples)
Ali Aslam
Ali Aslam

Posted on

Mastering Context and Async Data in Svelte (with Examples)

Imagine your boss sends a memo that only the intern at the end of the hall really needs. Instead of walking it over, the boss hands it to every manager in between, who each have to carry it down the chain. None of them care about the memo — but they’re stuck passing it along.

That’s what prop drilling feels like.


The pain: prop drilling 🕳️

Let’s see how this shows up in code. Say we’ve got a layout that defines a theme (light or dark). Deep inside that layout, we’ve got a Logo component that needs to know the theme.

Without context, we’re forced to pass theme as a prop through every level:
📂 src/lib/Logo.svelte

<script>
  export let theme;
</script>

<h1 class={theme}>My App</h1>
Enter fullscreen mode Exit fullscreen mode

📂 src/lib/Header.svelte

<script>
  import Logo from './Logo.svelte';
  export let theme;
</script>

<nav>
  <Logo theme={theme} />
</nav>
Enter fullscreen mode Exit fullscreen mode

📂 src/lib/Layout.svelte

<script>
  import Header from './Header.svelte';
  export let theme = 'light';
</script>

<div class={theme}>
  <Header theme={theme} />
</div>

<style>
  .light { background: #f8f8f8; color: #222; }
  .dark { background: #222; color: #f8f8f8; }
</style>
Enter fullscreen mode Exit fullscreen mode

And to use it:

📂 src/routes/+page.svelte

<script>
  import Layout from '$lib/Layout.svelte';
</script>

<Layout theme="dark" />
Enter fullscreen mode Exit fullscreen mode

The theme gets passed through every single level. Even Header, which doesn’t care about theme, has to pass it along just so Logo can use it.

That’s messy, brittle, and painful to maintain.


Enter context 🎒

Prop drilling makes components pass values around like a game of hot potato. Context fixes that by letting a parent set a value once, and any child (no matter how deep) can grab it directly.

Technically, Svelte gives us two helpers:

  • setContext(key, value) → called in a parent to provide.
  • getContext(key) → called in a child to consume.

Both must agree on the key (a string or symbol).

To make this nicer, we usually wrap the logic in a special component called a provider. The provider’s job is simple:

  1. Set some context once.
  2. Render its children inside.
  3. Let those children access the context without any prop-drilling.

We’ll build a ThemeProvider that manages a light/dark theme and makes it available to any nested component.


Example: ThemeProvider + ThemedButton

Let’s rewrite the messy example with context.

📂 src/lib/ThemeProvider.svelte

<script>
  import { setContext } from 'svelte';
  import { writable } from 'svelte/store';

  const theme = writable('light');
  setContext('theme', theme);

  function toggle() {
    $theme = $theme === 'light' ? 'dark' : 'light';
  }

  // Extract the `children` snippet from props
  let { children } = $props();
</script>

<div class={$theme}>
  <button onclick={toggle}>Toggle Theme</button>

  {@render children?.()}
</div>

<style>
  .light { background: #f8f8f8; color: #222; padding: 1rem; }
  .dark  { background: #222; color: #f8f8f8; padding: 1rem; }
</style>
Enter fullscreen mode Exit fullscreen mode

Here’s what’s happening:

  • We create a writable theme store.
  • We stash it in context under the key "theme".
  • The provider shows a toggle button to switch between themes.
  • Whatever components you nest inside <ThemeProvider> get rendered via {children}, and they all have access to the theme context.

The name ThemeProvider can be anything, the convention is to use the suffix 'Provider' in such cases. Real magic is the setContext part.


📂 src/lib/ThemedButton.svelte

<script>
  import { getContext } from 'svelte';

  // grab the store from context
  const theme = getContext('theme');
</script>

<button class={$theme}>
  I just follow the theme!
</button>

<style>
  .light { background: white; color: black; }
  .dark  { background: black; color: white; }
</style>
Enter fullscreen mode Exit fullscreen mode

This button doesn’t take a theme prop. Instead, it asks:

“Hey, is there a theme in context?”

Because it’s wrapped by ThemeProvider, the answer is yes — and it reacts automatically when the theme changes.


Trying it out

📂 src/routes/+page.svelte

<script>
  import ThemeProvider from '$lib/ThemeProvider.svelte';
  import ThemedButton from '$lib/ThemedButton.svelte';
</script>

<h1>Context Demo</h1>

<ThemeProvider>
  <p>This text is inside the provider.</p>
  <ThemedButton />
</ThemeProvider>
Enter fullscreen mode Exit fullscreen mode

Run this, and you’ll see:

  • A theme container with toggle button.
  • Themed text + button inside.
  • When you toggle, all children react automatically.

Now the flow is clear:

  1. ThemeProvider sets up the theme and provides it.
  2. Anything wrapped inside <ThemeProvider> becomes its child.
  3. Those children can grab the theme with getContext('theme').
  4. Toggling updates the theme everywhere at once.

No more props passed down through five layers. Just context.


Why store in context instead of plain values?

You could setContext('theme', 'light'). That works for static values.
But with a store:

  • Parents can update it ($theme = ...).
  • Children auto-subscribe with $theme.
  • Everyone stays in sync.

So the usual pattern is: put a store in context.


When to use what?

Here’s the decision tree:

  • Props → local, simple parent → child communication.
  • Stores → global state, accessible from anywhere.
  • Context → scoped shared state, only within a subtree.

Think of it like this:

  • Props = direct conversation.
  • Stores = bulletin board in the office.
  • Context = team backpack 🎒 that only your squad carries.

Async Data

We’ve got context under our belt. Now let’s switch gears: apps don’t live on islands.
They need data from APIs, databases, or local files. And that data doesn’t always arrive instantly — it’s async.


Why async data feels different

When you fetch data, there are always three states to think about:

  1. Loading — waiting for the response.
  2. Success — data arrived.
  3. Error — something went wrong.

If you don’t handle all three, your UI either looks broken or leaves users staring at a blank screen.

Svelte gives us a built-in tool to manage these states: the {#await} block.


The {#await} block

JavaScript’s fetch() returns a promise — a value that isn’t ready yet but will be in the future.
Instead of juggling loading flags and try/catch, Svelte lets you declare all three states inline.

📂 src/lib/JokeFetcher.svelte

<script>
  // Kick off a fetch that returns a promise
  let promise = fetch('https://api.chucknorris.io/jokes/random')
    .then(res => res.json());
</script>

<!-- {#await} handles loading, success, and error in one go -->
{#await promise}
  <p>Loading a hilarious joke…</p>
{:then data}
  <p>{data.value}</p>
{:catch error}
  <p style="color: red">Oops: {error.message}</p>
{/await}
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import JokeFetcher from '$lib/JokeFetcher.svelte';
</script>

<h1>Joke Demo</h1>
<JokeFetcher />
Enter fullscreen mode Exit fullscreen mode

👉 How it works:

  • While waiting, the first branch runs → “Loading…”.
  • When resolved, the data is passed to {:then}.
  • If rejected, we land in {:catch}.

This means no manual state tracking. The block itself drives the UI.


Refreshing data

So far, the joke loads once when the component mounts. But what if you want a new joke every click?

Solution: wrap the fetch in a function and reassign promise when you need new data.

📂 src/lib/JokeFetcher.svelte

<script>
  let promise;

  function loadJoke() {
    promise = fetch('https://api.chucknorris.io/jokes/random')
      .then(res => res.json());
  }

  // Fetch one on mount
  loadJoke();
</script>

<button onclick={loadJoke}>New Joke</button>

{#await promise}
  <p>Loading…</p>
{:then data}
  <p>{data.value}</p>
{:catch error}
  <p style="color: red">Error: {error.message}</p>
{/await}
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import JokeFetcher from '$lib/JokeFetcher.svelte';
</script>

<h1>Joke Demo with Refresh</h1>
<JokeFetcher />
Enter fullscreen mode Exit fullscreen mode

Each click starts the cycle again: Loading → Joke → Repeat.


Wrapping async logic in a store

Fetching data directly inside a component works for demos.
But in a real app it quickly gets messy:

  • You repeat the same fetch logic in multiple places.
  • Reloading means rewriting the same boilerplate.
  • Testing/debugging is harder because logic is scattered.

The fix: wrap your async logic in a custom store.
The store owns the fetching logic, tracks state, and exposes results.
Components just consume it.


Step 1 — Create a store

📂 src/lib/userStore.js

import { writable } from 'svelte/store';

export function createUserStore() {
  const { subscribe, set } = writable({
    status: 'loading',
    data: null,
    error: null
  });

  async function load() {
    set({ status: 'loading', data: null, error: null });
    try {
      // fetch a random user between 1 and 10
      const id = Math.floor(Math.random() * 10) + 1;
      const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
      const data = await res.json();

      set({ status: 'success', data, error: null });
    } catch (err) {
      set({ status: 'error', data: null, error: err });
    }
  }

  // fetch immediately when the store is created
  load();

  return { subscribe, reload: load };
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here?

  • We create a writable store with three fields: status, data, and error.
  • The load() function handles the entire fetch cycle:

    • Start in "loading" state.
    • On success → update with { status: 'success', data }.
    • On failure → set { status: 'error', error }.
  • The store exposes two things:

    • subscribe → so components can $subscribe.
    • reload → so components can manually trigger a new fetch.

Step 2 — Use it in a component

📂 src/lib/UserProfile.svelte

<script>
  import { createUserStore } from '$lib/userStore.js';

  // create the store
  const user = createUserStore();
</script>

{#if $user.status === 'loading'}
  <p>Loading user...</p>
{:else if $user.status === 'error'}
  <p style="color: red">Error loading user</p>
{:else}
  <h2>{ $user.data.name }</h2>
  <p>Email: { $user.data.email }</p>
  <!-- important: call user.reload, not $user.reload -->
  <button onclick={user.reload}>Reload</button>
{/if}
Enter fullscreen mode Exit fullscreen mode

Key points

  • $user is the current value of the store (status, data, error).
  • But reload is a method on the store object itself. That’s why we call user.reload, not $user.reload.
  • The component doesn’t fetch() at all. It just reacts to $user and calls user.reload() when needed.

Step 3 — Mount it in a page

📂 src/routes/+page.svelte

<script>
  import UserProfile from '$lib/UserProfile.svelte';
</script>

<h1>User Demo</h1>
<UserProfile />
Enter fullscreen mode Exit fullscreen mode

Now when you load the page:

  1. The store immediately fetches a random user.
  2. The component shows Loading → Success/Error.
  3. Clicking Reload fetches another random user.

Combining multiple async calls

Sometimes you need more than one piece of data before rendering.
For example: fetch a user and their posts.

📂 src/lib/UserWithPosts.svelte

<script>
  let promise = Promise.all([
    fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json()),
    fetch('https://jsonplaceholder.typicode.com/posts?userId=1').then(r => r.json())
  ]);
</script>

{#await promise}
  <p>Loading user and posts...</p>
{:then [user, posts]}
  <h2>{user.name}</h2>
  <ul>
    {#each posts as post}
      <li>{post.title}</li>
    {/each}
  </ul>
{:catch error}
  <p style="color: red">Failed: {error.message}</p>
{/await}
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import UserWithPosts from '$lib/UserWithPosts.svelte';
</script>

<h1>User + Posts Demo</h1>
<UserWithPosts />
Enter fullscreen mode Exit fullscreen mode

👉 With Promise.all, you can wait for multiple requests at once.
Notice how {:then [user, posts]} destructures both results directly.


Making loading states nicer: skeletons

Plain “Loading…” text works, but it feels unfinished. Modern UIs use skeletons — animated placeholders that look like content, making the app feel smoother and more polished during loading states.


📂 src/lib/Skeleton.svelte (Skeleton Component)

This component simulates loading content with a shimmer effect. It’s customizable and can be adjusted to fit various content sizes (e.g., text, buttons).

<script>
  export let width = '100%';   // Default width to fill the container
  export let height = '2rem';  // Default height for the skeleton
</script>

<!-- Skeleton div -->
<div class="skeleton" style="width: {width}; height: {height};"></div>

<style>
  .skeleton {
    background: linear-gradient(90deg, #f0f0f0, #e0e0e0, #f0f0f0);
    background-size: 200% 100%;  /* Make the shimmer effect visible */
    animation: shimmer 1.5s infinite;  /* Add shimmer effect animation */
    border-radius: 4px;            /* Rounded corners for skeletons */
    margin: 0.5rem 0;              /* Space between skeleton elements */
  }

  @keyframes shimmer {
    0% { background-position: -200% 0; }
    100% { background-position: 200% 0; }
  }
</style>
Enter fullscreen mode Exit fullscreen mode
  • Shimmer Animation: A shimmering effect that runs infinitely to simulate the loading process.
  • Customizable Width & Height: You can adjust the width and height of the skeletons to match the content they are replacing (e.g., user name, email, button).
  • Background Gradient: Creates a smooth visual transition to make the shimmer effect.

📂 src/lib/UserProfile.svelte (Using Skeletons)

This component displays the user's profile data or skeletons while loading. It uses the skeletons for elements that are not yet fetched.

<script>
  import { createUserStore } from '$lib/userStore.js';
  import Skeleton from '$lib/Skeleton.svelte';

  const user = createUserStore();  // Initialize the store for fetching user data
</script>

{#if $user.status === 'loading'}
  <!-- Display skeletons when loading -->
  <Skeleton width="60%" height="2rem" />   <!-- Simulate a user name skeleton -->
  <Skeleton width="80%" height="1.5rem" /> <!-- Simulate an email skeleton -->
  <Skeleton width="200px" height="2rem" /> <!-- Simulate a button skeleton -->
{:else if $user.status === 'error'}
  <p style="color: red">Error loading user</p>
{:else}
  <!-- Display actual content when loaded -->
  <h2>{ $user.data.name }</h2>
  <p>Email: { $user.data.email }</p>
  <button onclick={user.reload}>Reload</button>
{/if}
Enter fullscreen mode Exit fullscreen mode
  • Loading State: Skeletons are shown when the status is 'loading'.
  • Error Handling: If there's an error, a message is displayed.
  • Actual Content: When data is successfully fetched, the actual user details are displayed.

📂 src/routes/+page.svelte (Page Component)

This file includes the UserProfile component and displays it within the page.

<script>
  import UserProfile from '$lib/UserProfile.svelte';
</script>

<h1>User with Skeleton Demo</h1>
<UserProfile />
Enter fullscreen mode Exit fullscreen mode
  • This imports and uses the UserProfile component, which handles the loading, error, and success states.

✅ Result

  • Skeletons During Loading: Users will see animated placeholders (skeletons) instead of a blank screen or static “Loading…” text while the content is being fetched.
  • Smooth User Experience: The UI feels responsive even on slower networks because skeletons reassure users that the app is working behind the scenes.

✨ The Full Flow:

  1. {#await} for handling simple async logic.
  2. Functions for reloading or refreshing data.
  3. Stores for centralizing and reusing data-fetching logic.
  4. Promise.all for waiting on multiple async calls (e.g., loading user data and posts).
  5. Skeletons for a polished loading state.

By combining these concepts, you can create an efficient, user-friendly, and responsive app that minimizes blank screens and unnecessary fetch requests.


Mini Project: Themed Todo Dashboard

Let’s bring it all together:

  • Theme via context (from Chunk 1).
  • Todos in a store.
  • User profile fetched async with skeletons.

📂 src/lib/todoStore.js

import { writable } from 'svelte/store';

export const todos = writable([
  { id: 1, text: 'Learn Svelte', done: true },
  { id: 2, text: 'Build something cool', done: false }
]);
Enter fullscreen mode Exit fullscreen mode

📂 src/lib/TodoList.svelte

<script>
  import { todos } from '$lib/todoStore.js';
</script>

<h3>Todos</h3>
<ul>
  {#each $todos as todo}
    <li>
      <input type="checkbox" bind:checked={todo.done} />
      {todo.text}
    </li>
  {/each}
</ul>

<p>Completed: { $todos.filter(t => t.done).length } / { $todos.length }</p>
Enter fullscreen mode Exit fullscreen mode

📂 src/lib/UserProfile.svelte (async, with skeletons)

<script>
  let promise = fetch('https://jsonplaceholder.typicode.com/users/1')
    .then(r => r.json());
</script>

{#await promise}
  <p>Loading user...</p>
{:then user}
  <h3>User: {user.name}</h3>
  <p>Email: {user.email}</p>
{/await}
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import ThemeProvider from '$lib/ThemeProvider.svelte';
  import TodoList from '$lib/TodoList.svelte';
  import UserProfile from '$lib/UserProfile.svelte';
</script>

<ThemeProvider>
  <h1>Dashboard</h1>
  <UserProfile />
  <TodoList />
</ThemeProvider>
Enter fullscreen mode Exit fullscreen mode

Boom 💥 You now have:

  • A themed dashboard (context).
  • Async user profile (await).
  • A reactive todo list (store + derived count).

Recap

We just covered:

  • {#await} for loading/error/success.
  • Refreshing async data.
  • Wrapping fetches in stores.
  • Combining async calls.
  • Skeletons for UX polish.
  • A mini dashboard combining context + async + stores.

At this point, you can handle real-world data flow in Svelte without breaking a sweat.

Next up: SvelteKit Routing & Layouts: Building Multi-Page Apps the Easy Way


Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Checkout my offering on YouTube with (growing) crash courses and content on JavaScript, React, TypeScript, Rust, WebAssembly, AI Prompt Engineering and more: @LearnAwesome

Top comments (0)