In the meantime, Iām playing with a small SvelteKit app to read email newsletters.

The app has a basic API that wraps a Cloudflare SMTP bucket with two endpoints:
- List metadata for all emails
- Get data for a single email
In SvelteKit my single email template loads data asynchronously.
// email/[id]/+page.ts
import type {PageLoad} from "./$types";
import {fetchEmail} from "$lib/api";
export const load: PageLoad = ({fetch, params}) => {
return {
email: fetchEmail(fetch, params.id),
};
};fetchEmail is an async function that wraps the API and resolves an object with properties like: to, from, subject, body etc. When called client-side I cache the data in session storage because itāll never change (there is no delete functionality yet).
In the Svelte template I use the await template syntax to handle loading state.
<script lang="ts">
import type {PageProps} from './$types';
const {data}: PageProps = $props();
</script>
{#await data.email}
<h1>Loading email...</h1>
{:then email}
<h1>{email.subject}</h1>
{/await}Something like that, but spinnier.
Side Effects
I figured it would be helpful to mark emails as āreadā. I donāt want a server database so browser local storage will suffice. I can pass the URL id parameter and instantly set a key, but I want to wait until the full email data has loaded in case the request fails or is cancelled.
Because data.email is a promise I tried adding a callback at the top of the template.
<script lang="ts">
import type {PageProps} from './$types';
import {browser} from '$app/environment';
const {data}: PageProps = $props();
if (browser) {
data.email.then((email) => {
localStorage.setItem(email.id, "read");
});
}
</script>This kinda works, but not really. This is not
Svelte provides the $effect rune to mark such code as āreactiveā. Effects only run client-side so I can simply replace the browser conditional with an effect.
<script lang="ts">
import type {PageProps} from './$types';
const {data}: PageProps = $props();
$effect(() => {
data.email.then((email) => {
localStorage.setItem(email.id, "read");
});
});
</script>The Svelte compiler knows data.email is a dependency so it will execute the callback every time the data changes. There is no manual array like Reactās useEffect hook.
This works better! But there are bugs here too.
If I navigate between emails before the previous one has loaded the old promise callback lingers. I risk marking an email as āreadā that I didnāt actually read. The āeffectā of updating local storage is relatively harmless. Even so thatās a bug, and Iām also concerned about the use of stale props. The code smells all wrong.
One solution I tried was to use an abort controller.
<script lang="ts">
import type {PageProps} from './$types';
const {data}: PageProps = $props();
let controller: AbortController;
$effect(() => {
controller?.abort();
controller = new AbortController();
const {signal} = controller;
data.email.then((email) => {
if (signal.aborted) return;
localStorage.setItem(email.id, "read");
});
});
</script>That passes the sniff test, I think?
The controller is not a reactive $state because that would trigger the effect to run again. The signal is destructured outside the promise so that I can reference the correct signal inside the resolve callback if the controller changes.
If I wanted to avoid $effect I could use afterNavigate instead. And the fancy pants controller could be replaced with a counter (Iām just vibing there).
<script lang="ts">
import type {PageProps} from './$types';
import {afterNavigate} from '$app/navigation';
const {data}: PageProps = $props();
let sequence = 0;
afterNavigate(() => {
let current = ++sequence;
data.email.then((email) => {
if (current !== sequence) return;
localStorage.setItem(email.id, "read");
});
});
</script>I feel like Iām over-thinking this and missing a trick somewhere.
Svelte has new experimental await that gave me an idea.
<script lang="ts">
import type {PageProps} from './$types';
const {data}: PageProps = $props();
const id = $derived((await data.email).id);
$effect(() => {
localStorage.setItem(id, "read");
});
</script>Unfortunately this does not ācancelā any emails I skip over that havenāt finished loading. All promises resolve and the effect callback cycles through every one.
Hereās an entirely different (bad) idea that works.
<script lang="ts">
import type {PageProps} from './$types';
const {data}: PageProps = $props();
const getSubject = (email) => {
localStorage.setItem(email.id, "read");
return email.subject;
};
</script>
{#await data.email}
<h1>Loading email...</h1>
{:then email}
<h1>{getSubject(email)}</h1>
{/await}I worked it into the template logic because that can handle the ācancellationā. Of course, adding a side effect here is terrible code. I checked the Svelte docs and found the @attach syntax. Attach run effects when an element is mounted. Same idea but more explicit.
<script lang="ts">
import type { Attachment } from 'svelte/attachments';
import type {PageProps} from './$types';
const {data}: PageProps = $props();
const markRead: Attachment = (id) => {
localStorage.setItem(id, "read");
};
</script>
{#await data.email}
<h1>Loading email...</h1>
{:then email}
<h1 {@attach markRead(email.id)}>{email.subject}</h1>
{/await}Iām not keen on this, but Iāve never used @attach before, maybe itās fine?
A cleaner way would be to use two components to separate the async logic. The parent component goes back to basics.
<script lang="ts">
import type {PageProps} from './$types';
import Email from './email.svelte';
const {data}: PageProps = $props();
</script>
{#await data.email}
<h1>Loading email...</h1>
{:then email}
<Email {email} />
{/await}And the <Email> child component would be synchronous with an onMount.
<script lang="ts">
import {onMount} from 'svelte';
const {email} = $props();
onMount(() => {
localStorage.setItem(email.id, "read");
});
</script>
<h1>{email.subject}</h1>That looks better to me.
I canāt help but think Iām still missing a trick though! Does SvelteKit provide a better way to handle async page data effects that can be cancelled if data is replaced? Message me on the socials if you know!
(Donāt ask me how Iām retrieving the āreadā state from local storageā¦)
Update (later that dayā¦)
I successfully summoned Rich Svelte himself on Bluesky who shared a gem.
getAbortSignal()Returns an
AbortSignalthat aborts when the current derived or effect re-runs or is destroyed. Must be called while a derived or effect is running.
This fixes my original $effect example and handles the controller logic.
<script lang="ts">
import {getAbortSignal} from 'svelte';
import type {PageProps} from './$types';
const {data}: PageProps = $props();
$effect(() => {
const signal = getAbortSignal();
data.email.then((email) => {
if (signal.aborted) return;
localStorage.setItem(email.id, "read");
});
});
</script>That smells like excellent code to me!
Itās good to learn āthe right wayā to do these things. For my exact use case here itās unlikely an email would be marked incorrectly ā I had to test with synthetic latency ā and even if it was, whatever, Iāll probably remember what Iāve read. But next time the consequences might be more severe! Best not to leave bugs lying around.
Thanks for reading! Follow me on Mastodon and Bluesky. Subscribe to my Blog and Notes or Combined feeds.