sveltelean Query
Version v0.2.0

Example

Edit a member and save. Then try an email outside harbor.studio: the server answers with a field error.

Edit members

The example runs in the browser.

Requests the server received

  1. None yet.
Code
<!-- src/routes/members/+page.svelte -->
<script lang="ts">
	import { createMutation, createQuery } from '@svelte-lean/query';
	import { fieldErrors, issues } from '@svelte-lean/query/forms';
	import { listMembers, updateMember } from '$api/members';
	import type { Member } from '$api/types';

	const uid = $props.id();
	const members = createQuery(listMembers, () => ({}));

	let dialog = $state<HTMLDialogElement>();
	let editing = $state<Member | null>(null);
	// `invalidates` on updateMember refreshes tag('Member', id) and 'Members': this list, once.
	const save = createMutation(updateMember, { onSuccess: () => dialog?.close() });

	function edit(member: Member) {
		editing = member;
		save.reset();
		dialog?.showModal();
	}

	function submit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
		event.preventDefault();
		if (!editing) return;
		const form = new FormData(event.currentTarget);
		save
			.run({ id: editing.id, name: String(form.get('name')), email: String(form.get('email')) })
			.catch(() => {}); // the error is in save.error: field marks below
	}
</script>

{#if members.refreshing}<progress aria-label="Refreshing"></progress>{/if}
<ul>
	{#each members.data?.rows ?? [] as member (member.id)}
		<li>
			{member.name} · {member.email}
			<button type="button" onclick={() => edit(member)}>Edit {member.name}</button>
		</li>
	{/each}
</ul>

<dialog bind:this={dialog} aria-labelledby="{uid}-title" onclose={() => (editing = null)}>
	{#if editing}
		<form {@attach fieldErrors(() => save.error)} onsubmit={submit}>
			<h2 id="{uid}-title">Edit {editing.name}</h2>
			<div data-slean="field">
				<label>Name <input name="name" type="text" value={editing.name} /></label>
				<p data-slean-part="error" id="{uid}-name-error">
					{issues(save.error, 'name').join(' ')}
				</p>
			</div>
			<div data-slean="field">
				<label>Email <input name="email" type="email" value={editing.email} /></label>
				<p data-slean-part="error" id="{uid}-email-error">
					{issues(save.error, 'email').join(' ')}
				</p>
			</div>
			{#if save.error && !save.error.fields}<p role="alert">{save.error.message}</p>{/if}
			<button disabled={save.busy}>{save.busy ? 'Saving…' : 'Save'}</button>
			<button type="button" onclick={() => dialog?.close()}>Cancel</button>
		</form>
	{/if}
</dialog>
The component shown, against an in-memory server that validates the email domain and answers after a fixed delay.
src/lib/api/members.ts
export const updateMember = defineMutation({
	patch: 'api/members/{id}',
	body: type<Partial<MemberInput>>(),
	result: type<Member>(),
	invalidates: (member) => [tag('Member', member.id), 'Members'],
	access: 'members.edit'
});

How it works

MomentRequestScreen
SavePATCH api/members/1save.busy: the button is disabled; a second click starts nothing
The server answers 422not retriedaria-invalid, the message, custom validity and focus on the first field; editing the field clears its marks
The server acceptsinvalidates: Member:1 and Members; the list on screen refetches once, cause invalidateonSuccess closes the dialog; the list shows refreshing
Lists not on screennone: they are marked stale and refetch when shown—
  • fieldErrors() writes only attributes and custom validity, and removes exactly what it wrote; the messages are rendered by the template with issues().
  • error.fields is typed by the mutation's body, so issues(save.error, 'name') names a real field.
  • save.reset() clears the previous attempt when the dialog opens again.

Variations

Wait for the refreshed list

TypeScript
// Resolve run() only after the invalidated lists on screen have their new answer.
const create = createMutation(createMember, { settle: 'refetched' });

Invalidate by hand

TypeScript
// After a write the definitions do not describe (a bulk import, a server job):
invalidate(['Members', tag('Member', 7)]);