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} />
Option Notes
where Remult EntityFilter.
orderBy Defaults to the entity’s defaultOrderBy.
include Relations to load.
enabled false skips the query (keeps last result) until it flips true.
limit Cap rows (load / listen). No default.
pageSize Rows per page (paginate, default 25).
aggregate Aggregations computed alongside the page (paginate).
Property Type Shape
items Entity[] many
draft Entity | undefined many
item Entity | undefined one
loading { init, fetching, more, saving, deleting } both
isBusy/isWriting boolean (derived) both
error string | undefined both
hasNextPage boolean many/paginate
aggregates typed 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()

many and one are the data engine under firstly’s cell layer - metadata-driven grids and forms. firstly ships the headless primitives (buildCells, <FF_Cell>, the FF_Config.cell input registry); the opinionated FF_Grid (CRUD grid) and FF_Group (bound-record form) are boutique shells you copy from src/boutique/grid and own. Full chapter: cell layer.

<FF_Grid entity={Task} selected={['title']} where={{ done: false }} strategy="listen" />
<FF_Group entity={Task} selected={['title']} mode="edit" />

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/removeItem automatic - save() / remove(row)
r.repo.insert/findId/count/... repo(E).insert/findId/count/... (remult)
r.meta / r.error / r.refresh() unchanged