Skip to content

Data Adapters ​

A data adapter decides where a collection's data operations actually happen.

The collection itself does not query, write or store anything. It describes the operation — insert this item, update whatever matches that selector, keep me posted about this query — and hands it to its data adapter. The adapter answers it and reports back what changed.

js
import { Collection, DefaultDataAdapter } from '@signaldb/core'

const dataAdapter = new DefaultDataAdapter()

const Posts = new Collection('posts', dataAdapter)
const Authors = new Collection('authors', dataAdapter)

One adapter usually serves every collection of an application. It is asked for the storage belonging to each collection by name, which is why the collection's name is a constructor argument rather than an option.

Where it sits ​

SignalDB has three extension points, and they answer three different questions:

Question it answers
ReactivityAdapterHow do I tell your framework that something changed?
DataAdapterWhere do the data operations run?
StorageAdapterHow is a document persisted and read back?

A data adapter is the one in the middle. Most of them take a storage function and use a storage adapter underneath — the difference between them is not what they store but where and when the work happens.

Choosing one ​

DefaultDataAdapter ​

Keeps the data in memory on the main thread and answers every query from it. Writes are applied immediately and persisted in the background. This is the adapter you want unless something below applies, and the one a collection constructed without an adapter uses.

js
import { Collection, DefaultDataAdapter } from '@signaldb/core'
import createLocalStorageAdapter from '@signaldb/localstorage'

const dataAdapter = new DefaultDataAdapter({
  storage: name => createLocalStorageAdapter(name),
})

Reference →

AsyncDataAdapter ​

Answers every query by going to storage, without holding the collection in memory. Right when the data does not fit in memory, or when something else can change the underlying storage.

js
import { Collection, AsyncDataAdapter } from '@signaldb/core'
import createIndexedDBAdapter from '@signaldb/indexeddb'

const dataAdapter = new AsyncDataAdapter({
  storage: createIndexedDBAdapter({
    databaseName: 'my-app',
    version: 1,
    schema: { posts: ['authorId'] },
  }),
  onError: error => reportToCrashReporter(error),
  retry: {
    attempts: 5,
    delay: attempt => 250 * attempt,
  },
})

const Posts = new Collection('posts', dataAdapter, { indices: ['authorId'] })
  • storage is called once per collection with its name and is required.
  • A query that fails is retried before it is given up: retry.attempts counts every attempt including the first (default 3), retry.delay returns the wait in milliseconds before the next one (default 100 * 4 ** (attempt - 1)). Only once every attempt has failed is the query published as failed — see below.
  • onError receives what fails inside the adapter, including a query that has run out of attempts. Without it, the error goes to console.error.

Reference →

WorkerDataAdapter / WorkerDataAdapterHost ​

Runs the data layer in a web worker. WorkerDataAdapterHost lives inside the worker and owns the storage; WorkerDataAdapter lives on the main thread and talks to it. Right when queries or writes are large enough that doing them on the main thread costs you frames.

The worker file constructs the host with the worker's global scope and the storage:

js
// data-worker.js
import { WorkerDataAdapterHost } from '@signaldb/core'
import createIndexedDBAdapter from '@signaldb/indexeddb'

new WorkerDataAdapterHost(self, {
  id: 'app-data',
  storage: createIndexedDBAdapter({
    databaseName: 'my-app',
    version: 1,
    schema: { posts: ['authorId'] },
  }),
  onError: error => console.error('[data worker]', error),
})

The main thread starts the worker and hands it to the adapter, which your collections are then constructed with:

js
import { Collection, WorkerDataAdapter } from '@signaldb/core'

const worker = new Worker(new URL('./data-worker.js', import.meta.url), {
  type: 'module',
})
const dataAdapter = new WorkerDataAdapter(worker, { id: 'app-data' })

const Posts = new Collection('posts', dataAdapter, { indices: ['authorId'] })

Both halves must use the same id — every message carries it, and each side ignores messages with a different one. That is what lets several adapters share a worker, or several workers share a page. If you omit it on both sides they agree on the default, 'default-worker-data-adapter'.

The host announces itself when it is constructed. If the main thread does not hear from it within five seconds — the worker failed to load, or the ids do not match — every operation of the adapter fails with WorkerDataAdapter initialization timed out.

Reference →

AutoFetchDataAdapter ​

Fetches a query's items from a remote source the first time that query is registered, and drops them again when nothing is watching it any more. Right for data you want to pull on demand rather than sync in full — the successor to v1's AutoFetchCollection.

js
import { Collection, AutoFetchDataAdapter } from '@signaldb/core'
import createIndexedDBAdapter from '@signaldb/indexeddb'

const dataAdapter = new AutoFetchDataAdapter({
  storage: createIndexedDBAdapter({
    databaseName: 'my-app-cache',
    version: 1,
    schema: { posts: [] },
  }),
  fetchQueryItems: async (collectionName, selector) => {
    const query = encodeURIComponent(JSON.stringify(selector))
    const response = await fetch(`/api/${collectionName}?selector=${query}`)
    return response.json() // an array of items, each with an `id`
  },
  registerRemoteChange: async (onChange) => {
    const socket = new WebSocket('wss://example.com/changes')
    socket.addEventListener('message', () => { void onChange() })
  },
})

const Posts = new Collection('posts', dataAdapter)
  • storage is the local cache the fetched items are written to and every query is answered from. Leave it out and the items are cached in memory, so nothing survives a reload.
  • fetchQueryItems(collectionName, selector) is called when a selector is registered by its first observer. It must resolve to an array of items; an item that already exists locally is combined with the fetched one through mergeItems (default: a shallow spread, the fetched fields win).
  • registerRemoteChange is called once, when the adapter is constructed. Call the onChange it hands you whenever the remote data changed, and every selector that is currently observed is fetched again.
  • When the last observer of a selector goes away, the adapter waits purgeDelay milliseconds (default 10000, 0 purges at once) and then removes the items that selector fetched, unless another selector that has not been purged yet fetched them as well. Only items a fetch delivered are purged — even if you have written to them since; an item that only ever came from your own writes stays.

A query on a selector that is not observed yet is answered once its first fetch has settled: until then isLoading() is true and the cursor serves its neutral empty result, and an { async: true } read resolves after the fetched items have been stored. The answer then comes from the local cache, which holds the fetched items alongside everything stored earlier. If that fetch fails, the query settles as failed — see onError and the collection's query.error event. Later fetches of the same selector, triggered by registerRemoteChange, arrive as ordinary updates.

Reference →

Queries on these adapters are asynchronous ​

The async, worker and auto-fetch adapters cannot answer a query on the spot. A reactive cursor serves its neutral result — an empty list, a count of zero — until the answer arrives, so either await the result or check Cursor#isLoading() inside the reactive scope:

js
// outside a reactive scope: wait for the answer
const posts = await Posts.find({ authorId: 'user1' }, { async: true }).fetch()

// inside one: tell "not answered yet" apart from "nothing matched"
effect(() => {
  const cursor = Posts.find({ authorId: 'user1' })
  if (cursor.isLoading()) return renderSpinner()
  render(cursor.fetch())
})

A query that fails for good does not throw anywhere — the cursor keeps its neutral result and the collection emits query.error. See Queries that are not answered immediately for the full picture.

Writing your own ​

A data adapter is one method:

ts
import type { DataAdapter, CollectionBackend } from '@signaldb/core'

const myAdapter: DataAdapter = {
  createCollectionBackend(collection, indices) {
    // return an object implementing CollectionBackend
  },
}

createCollectionBackend is called once per collection and returns the object that answers everything for it: the six write methods, the query registration and reading methods, and two lifecycle methods. The full contract is on the DataAdapter reference page.

Two things are worth knowing before you start.

A query that has not been answered yet publishes a neutral result. An empty list is a legitimate answer, so a consumer cannot tell "nothing matched" from "not answered yet" by looking at the result. getQueryState is what makes the difference visible, and it is why an adapter that can fail must publish 'error' rather than leaving the query sitting on its empty value forever.

A delta is a promise about the previous result. When your adapter tells a listener that a query completed, it may pass a QueryDelta describing how the result changed — which saves the listener from comparing the whole new result against the whole old one. That delta must be relative to what getQueryResult returned the last time it was asked. If your adapter layers anything on top of its stored result — an optimistic write still in flight, say — omit the delta while it does. A wrong delta is worse than no delta: it desynchronises the consumer silently and permanently, and omitting it costs nothing but a comparison.

Released under the MIT License.