Querying Data
Like most databases, SignalDB lets you query your data. It uses an approach similar to MongoDB, where you can apply selectors to filter your data and use options to control things like sorting, projection, skipping, and limiting the results.
When you run a query with .find(), the query doesn’t execute right away. Instead, it returns a cursor, which you can use to call methods and get the actual data—just like in MongoDB. This makes it easier to only process what you need. For example, if you just need the .count() of a query, you don’t have to load all the data.
A unique feature of SignalDB is that all queries are reactive by default. This means if you run a query and use a function on the returned cursor within the effect or autorun function of your reactivity library, the query will automatically rerun whenever the data changes.
MongoDB-Style Queries in the Browser
SignalDB brings a MongoDB-like query API to the client. Selectors such as { age: { $gte: 18 }, tags: { $in: ['admin'] } }, sort and projection options and cursors work the way MongoDB developers expect, but run against local data in the browser or in Node.js. If you know Meteor's Minimongo, SignalDB will feel familiar: it offers a similar API outside of Meteor, works with any framework through reactivity adapters, and adds persistence and sync with any backend.
Queries
You can query you data by calling the .find() or .findOne() method of your collection. .findOne() returns the first found document while .find() returns a cursor.
Reactive or awaited
Where you read a query decides how you read it:
- Inside a reactive scope — an
effect, anautorun, a component's render — read it synchronously. The scope reruns whenever the result changes, so the cursor hands over what it holds right now. - Everywhere else — an event handler, a loader, a script, a test — pass
async: trueand await the result.
// inside a reactive scope
effect(() => {
render(collection.find({ status: 'published' }).fetch())
})
// everywhere else
const posts = await collection.find({ status: 'published' }, { async: true }).fetch()This holds for every data adapter, including the in-memory default one, where a synchronous read happens to return the data anywhere. A synchronous read outside a reactive scope is almost always a mistake: nothing keeps it up to date, and with an adapter that answers asynchronously it returns a neutral empty result instead of the data. SignalDB warns about it in the console. A collection without a reactivity adapter has no reactive scope at all, so it is always read with async: true.
Selectors
SignalDB uses the mingo library under the hood. It's very similar to MongoDB selectors. Check out their documentation to learn how a selector should look like: https://github.com/kofrasa/mingo
Options
The second parameter you can pass to the .find() method are the options. With the options you can control things like sorting, projection, skipping or limiting data.
Sorting
To sort the documention returned by a cursor, you can provide a sort object to the options. The object should contain the keys you want to sort and a direction (1 = ascending, -1 = descending)
collection.find({}, {
sort: { createdAt: -1 },
})Projection
You can also control which fields should be returned in the query. To do this, specify the fields object in the options of the .find() method.
TIP
With the fields option you can also control when you query will rerun. If you only query for a field that is not changing, the query will not rerun.
Also see Field-Level Reactitivity
collection.find({}, {
fields: { title: 1 },
})skip and limit
To skip or limit the result of a query, use the skip or limit options. Both options are optional.
collection.find({}, {
skip: 10,
limit: 10,
})Queries that are not answered immediately
The default adapter keeps your data in memory and answers a query on the spot. The async, worker and auto-fetch adapters cannot — they have to go to storage, across a worker boundary, or to a server. SignalDB gives you two ways to deal with that, and which one you want depends on where you are reading from.
Awaiting the result
Pass async: true and the cursor's methods resolve to their result instead of returning it:
const posts = await collection.find({ status: 'published' }, { async: true }).fetch()
const count = await collection.find({}, { async: true }).count()
const one = await collection.findOne({ id: 'abc' }, { async: true })The types follow the option, so a cursor without it stays synchronous and you do not end up awaiting things that were never promises.
This is the right form outside a reactive scope: an event handler, a loader, a script. It is not reactive — you get the result once.
Reading reactively
Inside an effect or autorun you want the query to rerun by itself, so it cannot hand you a promise. The cursor gives you what it has, and until the query has been answered that is a neutral result: fetch() gives an empty array, count() gives zero.
That neutral result is indistinguishable from a real one, because empty is a legitimate answer — plenty of queries genuinely match nothing. Cursor#isLoading() is what lets you tell the two apart:
const cursor = collection.find({ status: 'published' })
effect(() => {
if (cursor.isLoading()) return renderSpinner()
render(cursor.fetch())
})Rendering "no posts yet" during that window is the bug this prevents. With the default in-memory adapter isLoading() is false as soon as the collection is ready, so you rarely need it there.
TIP
isLoading() is always false on an { async: true } cursor — its fetch() already waits for the real result, so there is no window to report. The two forms are alternatives, not layers.
Observing changes
Reactive queries rerun a scope and hand you the whole result again. When you need to know what changed instead — to drive an animation, keep a third-party widget in step, or log activity — observe the cursor:
const cursor = Posts.find({ status: 'published' }, { sort: { createdAt: -1 } })
const stop = cursor.observeChanges({
added: item => {},
addedBefore: (item, before) => {},
changed: (item, previousItem) => {},
changedField: (item, field, oldValue, newValue) => {},
movedBefore: (item, before) => {},
removed: item => {},
})
// later
stop()added(item)— an item entered the result.addedBefore(item, before)— the same, with the item it now sits in front of, ornullwhen it was added at the end.changed(item, previousItem)— an item in the result changed; you get it as it is now and as it was before.changedField(item, field, oldValue, newValue)— called once per top-level field that differs between the two versions.movedBefore(item, before)— an item changed its position in a sorted result;beforeis the item it now precedes, ornullat the end.removed(item)— an item left the result, either because it was removed or because it no longer matches.
All callbacks are optional, and the items have the collection's transform applied. Unless you pass true as the second argument (skipInitial), the items already in the result are reported through added and addedBefore straight away. observeChanges returns the function that stops observing; call it, or cursor.cleanup(), once you are done, otherwise the query stays live. See the Cursor reference for details.
Collection events
A collection is also an event emitter. Its events describe the writes made through its own methods, independent of any query, and fire once the data layer has confirmed the write:
const onChanged = (item, modifier, previousItem) => {
console.log(`${item.id} changed`, modifier, previousItem)
}
Posts.on('added', item => {})
Posts.on('changed', onChanged)
Posts.on('removed', item => {})
Posts.once('query.error', (error, selector, options) => {})
// later
Posts.off('changed', onChanged)added(item)— an item was inserted.changed(item, modifier, previousItem)— an item was updated or replaced: the item as it is now, the modifier (or replacement) that was applied, and the item as it was before. All data adapters shipped with SignalDB reportpreviousItem; a custom one may leave it out.removed(item)— an item was removed; fired once per item.query.error(error, selector, options)— a live query failed and will not deliver a result. Its cursor keeps serving the neutral empty result, so this event is the only way to tell a failed query from one that matched nothing.
on subscribes, once subscribes for a single call, off unsubscribes the listener you passed to either of them. The full list of events is in the Collection reference.
Field-Level Reactivity
SignalDB introduces a powerful enhancement to its reactivity system called Field-Level Reactivity, which ensures that reactive functions (such as effect or autorun) only rerun when specific fields accessed in your code are changed. Previously, the reactive system would rerun the query if any field in any item of the result set was modified, regardless of whether those fields were actually used in the code. This led to unnecessary reactivity and potential performance bottlenecks, especially with large datasets.
Key Features
- Field-Level Reactivity: Reactive reruns now occur only when the fields actually accessed by your code are modified, rather than triggering for all changes in the dataset.
- Item-Level Reactivity: If a query returns multiple items but you only access fields from specific items, changes in unaccessed items will not trigger a rerun.
- Automatic Field Tracking: Instead of manually specifying which fields to track using the
fieldsoption, SignalDB now automatically tracks fields as you access them. This reduces the chance of developer oversight and simplifies code maintenance.
Opt-In to Field-Level Tracking
To enable field-level reactivity, there are three ways to configure field tracking: globally, per collection, or through the options parameter of the .find() method.
1. Global Configuration
To enable field tracking globally for all collections in your application, use the static method Collection.setFieldTracking. This ensures that field tracking is active by default across all collections unless overridden.
Collection.setFieldTracking(true) // Enables field tracking globally2. Per Collection Configuration
To configure field tracking for a specific collection, use the setFieldTracking method on that collection.
someCollection.setFieldTracking(true) // Enables field tracking for this collection only3. Enable Field Tracking in .find() Options
You can enable field tracking on a per-query basis by passing the fieldTracking: true option to the .find() method. When this option is set, reactivity is scoped to the fields you access.
effect(() => {
const items = someCollection.find({}, { fieldTracking: true }).fetch()
// Access the fields you care about here
console.log(items[0].name) // Will rerun only if 'name' field of the 0th item changes
})This behavior optimizes your app’s performance by reducing the number of unnecessary reruns. Instead of rerunning every time any field in any document changes, it only reruns when the relevant fields you’re interacting with are modified.
Benefits of Automatic Field Tracking
- Improved Performance: By reducing the scope of reactive reruns to only relevant data, SignalDB minimizes computational overhead and maximizes efficiency, particularly in scenarios where queries return large datasets or where irrelevant fields change frequently.
- Simplified Code: Developers no longer need to manually specify fields to track. With automatic field tracking, the system handles this for you, allowing you to focus on business logic rather than managing reactivity manually.
- Reduced Developer Error: Manually tracking fields can be error-prone, especially as queries evolve. Automatic field-level reactivity ensures that your queries remain optimal even as your code changes, making it easier to maintain over time.