Skip to content

ff (reactive layer)

ff (from firstly/svelte) exposes a Remult entity as Svelte 5 runes. There are two shapes:

  • ff(E).many(getter, strategy?) - a list plus an editing draft plus writes.
  • ff(E).one(getter) - a single record bound to item.

Both take a reactive options getter and expose reactive state (items, draft, loading, error, …) you read straight in markup. Everything imperative stays on remult’s repo(E).

<script lang="ts">
import { ff } from 'firstly/svelte'
import { Task } from '$lib/Task'
const tasks = ff(Task).many(() => ({ where: { done: false } }), 'listen')
</script>
{#if tasks.loading.init}
Loading…
{:else}
{#each tasks.items as t (t.id)}
<li>{t.title}</li>
{/each}
{/if}

many owns the list (items) and the current editing draft, with the writes wired together. The strategy is the fetch mode (default 'paginate'):

ff(E).many(() => ({ where })) // paginate (default): page + $count + more()
ff(E).many(() => ({ where }), 'listen') // liveQuery, auto-updates
ff(E).many(() => ({ where }), 'load') // one-shot, static

Editing flows through the draft; the list reconciles automatically (no manual addItem):

<script lang="ts">
const tasks = ff(Task).many(() => ({}), 'load')
</script>
<button onclick={() => tasks.create({ title: '' })}>+ New</button>
{#if tasks.draft}
<input bind:value={tasks.draft.title} placeholder={tasks.meta.fields.title.caption} />
<button disabled={tasks.isWriting} onclick={() => tasks.save()}>Save</button>
<button onclick={() => tasks.cancel()}>Cancel</button>
{/if}
{#each tasks.items as t (t.id)}
<li>
{t.title}
<button onclick={() => tasks.edit(t)}>Edit</button>
<button onclick={() => tasks.remove(t)}>Delete</button>
</li>
{/each}
  • edit(row) loads a row into draft; create(...) starts a blank draft. Pass the row, not its id - the id is read off it, so it works with any primary key, single or composite (id: ['a', 'b']), with no manual id juggling.
  • argless save() / remove() act on the draft; save(row) / remove(row) target any row.
  • cancel() drops the draft and clears error.
  • Reconcile by strategy: load inserts/updates at the sorted position, paginate refreshes, listen self-syncs via the liveQuery.
  • refresh() re-fetches (load / paginate).
tasks.edit(row) // default: clone in place (instant, isolated)
tasks.edit(row, { refetch: true }) // re-read fresh first (async), then edit

Default - clone, no fetch. draft becomes an isolated clone of the row you already have in items. Why this is the default: it’s instant (no round-trip, no loading flicker), and the clone keeps remult’s “existing-row” state, so save() is an update, not an insert. Because the clone is isolated, typing in the form does not touch the list row, and cancel() simply throws the clone away - the list never shows half-finished edits. This matches the natural mental model: you’re editing the row in front of you.

{ refetch: true } - re-read first. Why you’d opt in: when the list might be stale (a long-lived load, another user editing the same row) and you want the freshest server values before the user starts. The cost is a round-trip - draft is briefly undefined, so guard the form with {#if draft}. Default off, because the common case is “edit what I’m looking at,” and a refetch there just adds latency and a flash.

The confirm/show/cancel dance that every delete and edit button repeats, moved onto the handle. Built on dialog + the handle’s own edit/create/save/remove/cancel.

<!-- delete with confirm -->
<button onclick={() => tasks.confirmRemove(t, { message: 'Delete this task?' })}>Delete</button>
<!-- edit / create in a dialog -->
<button onclick={() => tasks.editInDialog(t, form)}>Edit</button>
<button onclick={() => tasks.createInDialog(form, { defaults: { title: '' } })}>+ New</button>
{#snippet form(close)}
{#if tasks.draft}
<input bind:value={tasks.draft.title} />
<button
disabled={tasks.isWriting}
onclick={async () => {
try {
await tasks.save()
close({ ok: true })
} catch {
/* tasks.error is set; dialog stays open */
}
}}
>
Save
</button>
<button onclick={() => close()}>Cancel</button>
{/if}
{/snippet}
  • confirmRemove(row, { message?, title?, confirmLabel?, cancelLabel?, danger?, toast? }) - confirms (danger defaults true), then remove(row). Resolves { ok: true } when removed, { ok: false } when cancelled or failed. A failure fills error and (unless toast: false) shows toast.fromError. It never re-throws, so onclick={() => list.confirmRemove(row)} is safe.
  • editInDialog(row, body, { refetch?, ...DialogOptions }) - seeds draft (clone, or refetch), opens body, and always cancel()s on close.
  • createInDialog(body, { defaults?, ...DialogOptions }) - blank draft (+ defaults), opens body, always cancel()s on close.
  • The body snippet binds draft and calls save() itself (a failed/validation save keeps the dialog open via error); these methods own only the seed + cleanup, never the save.

For an edit page: load one record into item, bind a form, save. Argless save() / delete() act on item; create(...) seeds a draft.

<script lang="ts">
let { id } = $props()
const r = ff(Task).one(() => ({ where: { id }, enabled: !!id }))
</script>
{#if r.item}
<input bind:value={r.item.title} />
<button disabled={r.loading.saving} onclick={() => r.save()}>Save</button>
<button disabled={r.loading.deleting} onclick={() => r.delete()}>Delete</button>
{/if}

onFirst(fn) (on many and one) seeds editable $state once, the moment the first row lands, and never re-fires. Why it exists: with a listen/live source you often want to seed a form (or a set of sliders, a draft) from the latest row, but a plain $derived/$effect would re-run on every live tick and overwrite whatever the user has since typed. onFirst fires exactly once on the first non-empty result, so the input then owns the value. For read-only display, prefer $derived(handle.items[0]) - reach for onFirst only when the seed must become independently editable.

The getter re-runs whenever the state it reads changes (a search box, an orderBy toggle, a route param) and re-fetches; stale in-flight responses are dropped. orderBy defaults to the entity’s defaultOrderBy. enabled: false skips the query (keeps the last result) until it flips true.

<script lang="ts">
let q = $state('')
const r = ff(Task).many(() => ({
where: q ? { title: { $contains: q } } : {},
enabled: q.length >= 2, // skip until 2 chars
}))
</script>
<input bind:value={q} />
OptionNotes
whereRemult EntityFilter.
orderByDefaults to the entity’s defaultOrderBy.
includeRelations to load.
enabledfalse skips the query (keeps last result) until it flips true.
limitCap rows (load / listen). No default.
pageSizeRows per page (paginate, default 25).
aggregateAggregations computed alongside the page (paginate).
PropertyTypeShape
itemsEntity[]many
draftEntity | undefinedmany
itemEntity | undefinedone
loading{ init, fetching, more, saving, deleting }both
isBusy/isWritingboolean (derived)both
errorstring | undefinedboth
hasNextPagebooleanmany/paginate
aggregatestyped result ($count + requested)many/paginate

The paginate strategy returns aggregates.$count (the total) for free alongside the page, and appends with more(). Pair it with the infiniteScroll attachment:

<script lang="ts">
import { ff, infiniteScroll } from 'firstly/svelte'
const r = ff(Task).many(() => ({ pageSize: 25 }), 'paginate')
</script>
{#each r.items as t (t.id)}<Row {t} />{/each}
<p>{r.aggregates?.$count ?? ''} total</p>
<div
{@attach infiniteScroll({
hasMore: () => r.hasNextPage,
loading: () => r.loading.more,
onMore: () => r.more(),
})}
></div>

No can* wrappers. Use remult’s metadata, which reflects the current remult.user:

<button disabled={!r.meta.apiInsertAllowed()}>Add</button>
<button disabled={!r.meta.apiDeleteAllowed(row)}>Delete</button>

The handle has no .repo - on purpose. Why: ff exists only to add Svelte reactivity (an $effect that re-runs your getter), so a reactive handle belongs at component init, not in a click handler. The moment you’re in an imperative spot - a button handler, an async function, a one-off count - there’s no reactivity to add, so you go straight to remult’s repo(E) (plain values in, a Promise out). One rule, no overlap: reactive reads/edits on the handle, everything else on repo(E).

import { repo } from 'remult'
async function open(id: string) {
const task = await repo(Task).findId(id)
}
await repo(Task).deleteMany({ where: { done: true } })
const n = await repo(Task).count()

DemoGrid (a full CRUD grid from one many handle) and DemoForm (a one bound form) ship from firstly/svelte as ready demos and starting points:

<script lang="ts">
import { DemoForm, DemoGrid } from 'firstly/svelte'
</script>
<DemoGrid entity={Task} fields={['title']} where={{ done: false }} strategy="listen" />
<DemoForm entity={Task} fields={['title']} />

For typing component props that receive a handle:

import type { FF_Many, FF_One } from 'firstly/svelte'
let { list }: { list: FF_Many<Task> } = $props()
let { record }: { record: FF_One<Task> } = $props()

Also exported: FF_Builder (the ff(E) return), FF_RepoOptions, FF_RepoLoading, ManyStrategy, AggregateOptions, QueryOptionsHelper.

ffRepo is replaced by ff. The read verbs become a strategy, and the list now carries its own editing draft (so a separate one editor is optional).

Old (ffRepo)New (ff)
ffRepo(E).load(() => ({ where }))ff(E).many(() => ({ where }), 'load')
ffRepo(E).listen(() => ({}))ff(E).many(() => ({}), 'listen')
ffRepo(E).paginate(() => ({}))ff(E).many(() => ({}), 'paginate')
ffRepo(E).one(() => ({ where }))ff(E).one(() => ({ where }))
r.addItem/updateItem/removeItemautomatic - save() / remove(row)
r.repo.insert/findId/count/...repo(E).insert/findId/count/... (remult)
r.meta / r.error / r.refresh()unchanged