On this page

Query

Read data from the server without side-effects.

Query is a wrapper around SvelteKit's query(). It lets you expose read-only data fetching to the client. Think of queries like HTTP GET requests: they should be free of side effects.

Define a query by passing an effect (or an effect-returning function) to Query:

post.remote.ts
import { Effect } from "effect";
import { db } from "$lib/server/database";
import { Query } from "svelte-effect-runtime";

export const GetPosts = Query(
	Effect.gen(function* () {
		return yield* db.sql`
			SELECT title, slug
			FROM post
			ORDER BY published_at DESC
		`;
	}),
);

On the client you call the exported function and yield* the result inside an effect script. The call returns an effect that resolves to your data:

+page.svelte
<script effect>
	import { GetPosts } from "./post.remote.ts";

	const posts = yield* GetPosts();
</script>

{#each posts as post}
	<p>{post.title}</p>
{/each}

The value returned by calling a query (before yielding) is also a resource object. It carries reactive state and helper methods even if you never yield it:

+page.svelte
<script effect>
	import { GetPosts } from "./post.remote.ts";

	const postsQuery = GetPosts();
</script>

<p>Loaded: {postsQuery.ready}</p>

Arguments

Pass a schema as the first argument to validate and decode input on the server before your handler runs.

Using an Effect Schema:

post.remote.ts
import { Effect, Schema } from "effect";
import { db } from "$lib/server/database";
import { Error, Query } from "svelte-effect-runtime";

export const GetPost = Query(Schema.String, (slug) =>
	Effect.gen(function* () {
		const [post] = yield* db.sql`
			SELECT *
			FROM post
			WHERE slug = ${slug}
		`;

		if (!post) {
			return yield* Error("NotFound", "Post not found");
		}

		return post;
	}),
);

Using a struct schema gives you a typed object:

post.remote.ts
import { Schema } from "effect";
import { Query } from "svelte-effect-runtime";

export const GetPost = Query(Schema.Struct({ slug: Schema.String }), ({ slug }) =>
	Effect.succeed({ slug }),
);

You can also use "unchecked" to opt out of validation entirely (the input type is whatever the caller sends):

post.remote.ts
import { Query } from "svelte-effect-runtime";

export const GetPost = Query("unchecked", (slug: string) => Effect.succeed(slug));

The query resource

Calling a query function returns an object that is both:

  • An effect you can yield* to obtain the data (A)
  • A resource with reactive fields and methods

Available on every query call result:

  • current – last successful value (or undefined)
  • loading, ready, error
  • refresh() – returns an effect that forces a refetch
+page.svelte
<script effect>
	import { GetPost } from "./post.remote.ts";

	const post = GetPost("hello-world");
</script>

{#if post.ready}
	<h1>{post.current.title}</h1>
{:else if post.loading}
	<p>Loading…</p>
{/if}

Refreshing

Call .refresh() to invalidate the cached value and fetch fresh data:

+page.svelte
<button onclick={yield* GetPosts().refresh()}>
	Refresh posts
</button>

Deduplication

SvelteKit automatically deduplicates identical query calls using a serialized argument as the cache key.

  • On the server: work only runs once per request for the same arguments.
  • On the client: concurrent identical calls share the same resource while it is in use.

SER inherits this behavior for free:

+page.svelte
<script effect>
	import { GetData } from "./data.remote.ts";

	const data = GetData();
</script>

<p>{yield* data}</p>

<!-- Reuses the same underlying request / cache entry -->
<button onclick={yield* GetData()}>
	Also uses cached data
</button>

Query.batch

Query.batch lets multiple calls in the same macro-task be sent to the server together. Your handler receives the array of inputs and returns a resolver function:

(inputs: readonly Input[]) => (input: Input, index: number) => Output;
weather.remote.ts
import { Effect, Schema } from "effect";
import { db } from "$lib/server/database";
import { Query } from "svelte-effect-runtime";

export const GetWeather = Query.batch(Schema.String, (cityIds) =>
	Effect.gen(function* () {
		const rows = yield* db.sql`
			SELECT *
			FROM weather
			WHERE city_id = ANY(${cityIds})
		`;

		const byId = new Map(rows.map((r) => [r.city_id, r]));

		return (cityId, index) => byId.get(cityId) ?? null;
	}),
);

Batching only affects transport. Each caller still receives its own result.

Query.live

A normal Query asks the server for one value. Query.live sounds as though it might run that query over and over, although that is not what happens. It opens a remote source and gives the client a Stream from Effect.

The handler must return Effect's Stream.Stream<A, E, R> data type directly. A number such as 42, an object, an array, or a Promise does not satisfy that contract. Neither does Effect.succeed(42) or Effect.succeed(Stream.make(42)): both are single effects, and Query.live does not run an effect merely to see whether a Stream is hiding inside it.

The Stream itself may emit perfectly ordinary JavaScript values. A Stream<number> emits numbers, for instance, while a Stream<User> emits user objects. What matters at the handler boundary is the container: return the Stream, not one of the values it may eventually emit.

QueryQuery.live
Server handler returnsAn effect that resolves to one valueA Stream from Effect that may emit many values
Calling it on the client returnsA query effect/resourceRemoteLiveStream<A, E>
A direct yield* readsThe query resultOnly the first emitted Stream element
Keeping it activeSvelteKit retains the cached query resourceA Stream runner consumes it until interrupted

This is the part that differs from most remote functions. Calling the export does not immediately hand you a user, count, date, or other application value. Instead, it gives you the Stream that will carry those values, which is why operators such as Stream.map, Stream.filter, and Stream.runForEach are the natural client API.

time.remote.ts
import { Stream } from "effect";
import { Query } from "svelte-effect-runtime";

export const Clock = Query.live(
	Stream.tick("1 second").pipe(Stream.map(() => new Date().toISOString())),
);

With input, the validator forms are the same as for an ordinary query. The caller passes the encoded value and, once validation succeeds, the handler receives the decoded one:

room.remote.ts
import { Schema, Stream } from "effect";
import { Query } from "svelte-effect-runtime";

export const WatchRoom = Query.live(Schema.String, (room_id) =>
	Stream.make({ room_id, state: "connected" as const }),
);

Here, the handler returns Stream.make(...); the room object is merely an element emitted by that Stream. If the handler returned the object by itself, the server would raise InvalidLiveQueryReturnError when it invoked the handler.

For a query without input, pass either a Stream or a callback that returns one. An input-bearing handler takes a schema or "unchecked"; once a validator is present, the second argument must be a function that returns a Stream.

Consume the stream

Calling Clock() returns a RemoteLiveStream immediately, before any clock value has been selected. That object is the live sequence itself, with SER's transport metadata attached so Live.status(...) and Live.reconnect(...) can still reach the underlying connection.

+page.svelte
<script lang="ts" effect>
	import { Clock } from "./time.remote.ts";
	import { Effect, Stream } from "effect";
	import { Live } from "svelte-effect-runtime";

	let time = $state("Connecting...");
	const clock = Clock();

	yield* clock.pipe(
		Stream.runForEach((next) =>
			Effect.sync(() => {
				time = next;
			}),
		),
	);
</script>

<p>{time}</p>

<button onclick={yield* Live.reconnect(clock)}>
	Reconnect
</button>

A direct yield* clock asks the Stream for one value and waits for its first element. Once that element arrives, the yield is complete; it does not quietly leave a subscription running in the background. If the Stream finishes without emitting, the effect fails with EmptyStreamYieldError.

When later elements matter, use a Stream runner instead. Stream.runForEach(...) stays attached until the Stream ends, fails, or the surrounding script effect is interrupted. In the example above, each arriving element is copied into Svelte state.

Compose values and transport state

Stream-preserving operators keep the live transport attached:

const labels = Clock()
	.pipe(Stream.map((time) => `Updated at ${time}`))
	.pipe(Stream.filter((label) => label.length > 0));

Live.status(labels) returns another stream whose states are "Connecting", "Open", "Failed", and "Closed". A manual reconnect, meanwhile, is an effect returned by Live.reconnect(labels), so it must be yielded or run. Derived live streams retain the hidden transport metadata both helpers need.

Ordinary stream operators handle retry policies as well:

import { Schedule, Stream } from "effect";

const ResilientClock = Clock().pipe(
	Stream.retry(Schedule.exponential("100 millis").pipe(Schedule.take(5))),
);

Moving from the old live resource

The 4.0.0 client is stream-native. These replacements cover the old resource API:

Before4.0.0
const clock = yield* Clock()const clock = Clock()
clock.currentLocal state updated through Stream.runForEach(...)
clock.ready, clock.loading, or clock.connectedConsume Live.status(clock)
yield* clock.reconnect()yield* Live.reconnect(clock)

Async generators and native iterables are valid sources, albeit only after they have been wrapped as a Stream from Effect:

counter.remote.ts
import { Stream } from "effect";
import { Query } from "svelte-effect-runtime";

async function* count_to(limit: number) {
	for (let count = 0; count < limit; count += 1) {
		yield count;
	}
}

export const Counter = Query.live("unchecked", (limit: number) =>
	Stream.fromAsyncIterable(count_to(limit), (error) => error),
);

Whether a Stream fails before or after an emission, the failure arrives through the client's RemoteFailure error channel. When the component unmounts or a tracked dependency changes, the script effect interrupts its consumer and forwards cleanup to the iterator as the downstream side closes.