Skip to content

touchifyapp/svelte-local-query

Repository files navigation

svelte-local-query

The SvelteKit remote functions API — query, query.batch, query.live, command, form — for apps without a backend. Your functions run locally in the browser (IndexedDB, SQLite-WASM, localStorage, in-memory, …) but you keep the exact same ergonomics: reactive, cached, awaitable queries; commands with optimistic updates; progressive form handling with typed fields and validation.

Works in any Svelte 5 app — plain Vite SPA or SvelteKit (e.g. in SPA mode). Runes-only, fully typed, no dependencies beyond Svelte itself.

The implementation is a direct port of SvelteKit's client-side remote-functions runtime with the HTTP transport removed. Behavior intentionally matches kit; every divergence is documented in DIFFERENCES.md.

Installation

npm install svelte-local-query

Requires svelte@^5.29 and a bundler that understands the svelte export condition (Vite with @sveltejs/vite-plugin-svelte, or SvelteKit).

Quick start

Declare your functions in any module — a .ts, .svelte.ts or <script module> block (module level, so the cache is shared across components):

// data.ts
import * as v from 'valibot'; // any Standard Schema library (zod, valibot, arktype...)
import { command, form, query } from 'svelte-local-query';
import { db } from './db'; // your local storage layer

export const getPosts = query(async () => {
	return db.posts.orderBy('date').toArray();
});

export const getPost = query(v.string(), async (id) => {
	return db.posts.get(id);
});

export const addLike = command(v.string(), async (id) => {
	await db.posts.update(id, { likes: (await db.posts.get(id)).likes + 1 });
});

export const createPost = form(
	v.object({
		title: v.pipe(v.string(), v.nonEmpty('Please enter a title')),
		content: v.string()
	}),
	async (data) => {
		const id = crypto.randomUUID();
		await db.posts.add({ id, ...data });
		return { id };
	}
);

Use them in components:

<script>
	import { getPosts, addLike, createPost } from './data';

	const posts = getPosts();
</script>

<!-- reactive access -->
{#if posts.error}
	<p>Something went wrong</p>
{:else if !posts.ready}
	<p>Loading...</p>
{:else}
	{#each posts.current as post}
		<article>
			<h2>{post.title}</h2>
			<button onclick={() => addLike(post.id)}>❤️ {post.likes}</button>
		</article>
	{/each}
{/if}

<!-- or await-based, with <svelte:boundary> -->
<!-- {#each await getPosts() as post} ... {/each} -->

<form {...createPost}>
	<label>
		Title
		<input {...createPost.fields.title.as('text')} />
		{#each createPost.fields.title.issues() ?? [] as issue}
			<span class="error">{issue.message}</span>
		{/each}
	</label>
	<textarea {...createPost.fields.content.as('text')}></textarea>
	<button disabled={!!createPost.pending}>Publish</button>
</form>

Queries with the same argument share a single cached instance, no matter where they are called from. When nothing references a query anymore, its cache entry is released automatically.

API

The API mirrors SvelteKit remote functions — the official docs apply almost verbatim. A condensed tour:

query

const getTodos = query(async () => [...]);                             // no argument
const search   = query((f: { filter?: string }) => {...});             // TS-inferred argument
const getTodo  = query(v.string(), async (id) => {...});               // validated argument
const legacy   = query('unchecked', async (filters: Filters) => {...}); // typed, unvalidated

Since everything runs locally (no trust boundary), the argument type can be inferred straight from the handler's parameter — no schema, no 'unchecked' (this is a local-only extension to the kit API). Use a schema whenever the value comes from outside your code.

Calling getTodo(id) returns a LocalQuery<T>:

  • current — latest value (undefined until ready)
  • ready / loading / error — reactive state (loading is also true during refreshes)
  • awaitable: await getTodo(id) resolves to the value
  • refresh() — re-run the function
  • set(value) — replace the value without re-running
  • withOverride(fn) — optimistic override for use with .updates(...)

query.batch

Solves n+1 against your local store: calls within the same macrotask are collected, the handler receives all arguments at once and returns a resolver to fan results back out.

const getUser = query.batch(v.string(), async (ids) => {
	const users = await db.users.bulkGet(ids);
	const lookup = new Map(users.map((u) => [u.id, u]));
	return (id) => lookup.get(id);
});

query.live

The handler returns an AsyncIterable (usually an async generator); every yielded value becomes current. Back it with whatever emits changes — BroadcastChannel, storage events, IndexedDB observers, timers:

const onlineUsers = query.live(async function* () {
	const channel = new BroadcastChannel('presence');
	try {
		yield await currentUsers();
		while (true) {
			await new Promise((resolve) => channel.addEventListener('message', resolve, { once: true }));
			yield await currentUsers();
		}
	} finally {
		channel.close();
	}
});

The instance additionally exposes connected, done, reconnect() and is async-iterable itself (for await (const users of onlineUsers())).

command

const addTodo = command((text: string) => db.todos.add({ text })); // TS-inferred argument
const addSafe = command(v.string(), async (text) => {
	// schema-validated
	await db.todos.add({ text });
});
  • addTodo.pending — reactive count of in-flight executions.
  • Refresh what changed, either inside the handler (getTodos().refresh()) or from the call site with single-flight semantics — the awaited promise resolves only after the refreshed queries have re-run:
const todos = getTodos();

await addTodo(text).updates(
	todos.withOverride((current) => [...current, { text }]) // optimistic
);

.updates(...) accepts query functions (refresh all active instances), query instances, and withOverride releases.

form

const updateTodo = form(v.object({ id: v.string(), text: v.string() }), async (data, issue) => {
	if (await isDuplicate(data.text)) invalid(issue.text('Already exists'));
	await db.todos.put(data);
});
  • Spread onto a <form>: <form {...updateTodo}>.
  • fields — typed accessors: fields.text.as('text') (spreadable input props with name, coercion prefixes, aria-invalid), .value(), .set(), .issues(), fields.allIssues(). Nested paths (fields.user.emails[0]) work.
  • Schema failures skip the handler and populate issues(); invalid() + the issue proxy create issues imperatively.
  • result, pending, submitted, element, programmatic submit(), validate({ includeUntouched, preflightOnly }), preflight(schema).
  • for(key) creates keyed instances for forms in a loop (updateTodo.for(todo.id)), injecting the key as data.id.
  • enhance(callback) customizes submission; combine with .updates(...) for optimistic updates:
<form
	{...updateTodo.enhance(async (form) => {
		await form.submit().updates(todos.withOverride((t) => optimistically(t)));
		form.element.reset();
	})}
>

By default a successful submission refreshes all active queries (the local equivalent of kit's invalidateAll()); taking control via .updates(...) or by refreshing/setting queries inside the handler disables that.

init — app-level hooks

import { init } from 'svelte-local-query';
import { goto } from '$app/navigation'; // or your router

init({
	redirect: (location) => goto(location), // handles redirect(...) from query/form handlers
	onerror: (error) => showToast(error) // form submission errors (default: rethrow)
});

Other exports

redirect(location), isRedirect, Redirect, invalid(...issues), isValidationError, ValidationError, and the full set of types: LocalQuery, LocalQueryFunction, LocalCommand, LocalForm, LocalLiveQuery, LocalResource, LocalFormIssue, … (structural ports of kit's Remote* types).

Differences from SvelteKit remote functions

Everything transport-related is gone, and a handful of behaviors necessarily change (no prerender, no non-JS form fallback, JSON cache keys, direct validation errors, pluggable redirects, …). The complete annotated list lives in DIFFERENCES.md.

Development

npm test          # vitest suite (jsdom; --expose-gc for cache-eviction tests)
npm run test:e2e  # Playwright suite driving the playground in Chromium
npm run check     # svelte-check, strict TS
npm run build     # svelte-package + publint
npm run dev       # vite playground (the page the e2e suite drives)

Acknowledgements

The architecture and most of the runtime are ported from SvelteKit's remote-functions client (MIT License, © the Svelte contributors). This project just removes the network.

License

MIT

About

Library that mimics the svelte remote functions API for SPA application

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages