Subscriptions & Deep Loading
Jazz's Collaborative Values (such as CoMaps or CoLists) are reactive. You can subscribe to them to automatically receive updates whenever they change, either locally or remotely.
You can also use subscriptions to load CoValues deeply by resolving nested values. You can specify exactly how much data you want to resolve and handle loading states and errors.
You can load and subscribe to CoValues in one of two ways:
- shallowly — all of the primitive fields are available (such as strings, numbers, dates), but the references to other CoValues are not loaded
- deeply — some or all of the referenced CoValues have been loaded
Subscription Hooks
On your front-end, using a subscription hook is the easiest way to manage your subscriptions. The subscription and related clean-up is handled automatically, and you can use your data like any other piece of state in your app.
Subscribe to CoValues
The CoState
class allows you to reactively subscribe to CoValues in your Svelte components. It will subscribe to updates when the component mounts and unsubscribe when it unmounts, ensuring your UI stays in sync and avoiding memory leaks.
Note: If you don't need to load a CoValue's references, you can choose to load it shallowly by omitting the resolve query.
Subscribe to the current user's account
AccountCoState
is similar to CoState
, but it returns the current user's account. You can use this at the top level of your app to subscribe to the current user's account profile and root.
Loading States
When you load or subscribe to a CoValue through a hook (or directly), it can return one of three states:
undefined
→ an interim state while Jazz is fetching the CoValuenull
→ a failure state. Either the CoValue couldn't be found, or it isn't accessible (e.g. due to permissions)Value
→ The successfully loaded CoValue instance
Check the examples above for practical demonstrations of how to handle these three states in your application.
Deep Loading
When you're working with related CoValues (like tasks in a project), you often need to load nested references as well as the top-level CoValue.
This is particularly the case when working with CoMaps that refer to other CoValues or CoLists of CoValues. You can use resolve
queries to tell Jazz what data you need to use.
Using Resolve Queries
A resolve
query tells Jazz how deeply to load data for your app to use. We can use true
to tell Jazz to shallowly load the tasks list here. Note that this does not cause the tasks themselves to load, just the CoList that holds the tasks.
const Task = co.map({ title: z.string(), description: co.plainText().optional(), get subtasks() { return co.list(Task).optional() } }); const Project = co.map({ name: z.string(), tasks: co.list(Task) }); const project = await Project.load(projectId); if (!project) throw new Error("Project not found or not accessible"); // This will be loaded project.name; // string // This *may not be loaded*, and *may not be accessible* project.tasks; // undefined | null | ListOfTasks const projectWithTasksShallow = await Project.load(projectId, { resolve: { tasks: true } }); if (!projectWithTasksShallow) throw new Error("Project not found or not accessible"); // This list of tasks will be shallowly loaded projectWithTasksShallow.tasks; // ListOfTasks // We can access the properties of the shallowly loaded list projectWithTasksShallow.tasks.length; // number // This *may not be loaded*, and *may not be accessible* projectWithTasksShallow.tasks[0]; // undefined | null | Task
We can use an $each
expression to tell Jazz to load the items in a list.
const projectWithTasks = await Project.load(projectId, { resolve: { tasks: { $each: true } } }); if (!projectWithTasks) throw new Error("Project not found or not accessible"); // The task will either be loaded or null if it is not accessible projectWithTasks.tasks[0]; // Task | null // Primitive fields are always loaded projectWithTasks.tasks[0].title; // string // References on the Task may not be loaded, and may not be accessible projectWithTasks.tasks[0].subtasks // undefined | null | ListOfTasks // CoTexts are CoValues too projectWithTasks.tasks[0].description; // undefined | null | CoPlainText
We can also build a query that deeply resolves to multiple levels:
const projectDeep = await Project.load(projectId, { resolve: { tasks: { $each: { subtasks: { $each: true }, description: true } } } }); if (!projectDeep) throw new Error("Project not found or not accessible"); // Primitive fields are always loaded projectDeep.tasks[0].subtasks[0].title; // string // The description will either be loaded or null if it is not accessible projectDeep.tasks[0].description; // CoPlainText | null
If you access a reference that wasn't included in your resolve
query, you may find that it is already loaded, potentially because some other part of your app has already loaded it. You should not rely on this.
Expecting data to be there which is not explicitly included in your resolve
query can lead to subtle, hard-to-diagnose bugs. Always include every nested CoValue you need to access in your resolve
query.
Where To Use Resolve Queries
The syntax for resolve queries is shared throughout Jazz. As well as using them in load
and subscribe
method calls, you can pass a resolve query to a front-end hook.
Loading Errors
A load operation will be successful only in case all references requested (both optional and required) could be successfully loaded, otherwise the load operation will return null in order to avoid potential inconsistencies.
// If permissions on description are restricted: const task = await Task.load(taskId, { resolve: { description: true } }); task // null
This is also true if any element of a list is inaccessible, even if all the others can be loaded.
// One task in the list has restricted permissions const project = await Project.load(projectId, { resolve: { tasks: { $each: true } } }); project // null
Loading will be successful if all requested references are loaded, even if other references may not be accessible.
// One task in the list has restricted permissions const project = await Project.load(projectId, { resolve: true }); if (!project) throw new Error("Project not found or not accessible"); // Assuming the user has permissions on the project, this load will succeed, even if the user cannot load one of the tasks in the list project // Project | null project.tasks[0] // Task | null
Catching loading errors
We can use $onError
to handle cases where some data you have requested is inaccessible, similar to a try...catch
block in your query.
For example, in case of a project
(which the user can access) with three task
items:
Task | User can access task ? | User can access task.description ? |
---|---|---|
0 | ✅ | ✅ |
1 | ✅ | ❌ |
2 | ❌ | ❌ |
Scenario 1: Skip Inaccessible List Items
If some of your list items may not be accessible, you can skip loading them by specifying $onError: null
, and inaccessible items will be null
, while accessible items load properly.
// Inaccessible tasks will be null, but the project will be loaded const project = await Project.load(projectId, { resolve: { tasks: { $each: true, $onError: null } } }); if (!project) throw new Error("Project not found or not accessible"); if (!project.tasks) throw new Error("Project's task list not found or not accessible"); project // Project project.tasks[0]; // Task project.tasks[1]; // Task project.tasks[2]; // null
Scenario 2: Handling Inaccessible Nested References
An $onError
applies only in the block where it's defined. If you need to handle multiple potential levels of error, you can nest $onError
handlers.
This load will fail, because the $onError
is defined only for the task.description
, not for failures in loading the task
itself.
const project = await Project.load(projectId, { resolve: { tasks: { $each: { description: true, $onError: null }, } } }); // The load fails because task[2] is inaccessible and no $onError caught it. project // null
We can fix this by adding handlers at both levels
const project = await Project.load(projectId, { resolve: { tasks: { $each: { description: true, $onError: null // catch errors loading task descriptions }, $onError: null // catch errors loading tasks too } } }); project // Project project.tasks[0]; // Task project.tasks[0]?.description; // CoPlainText project.tasks[1]; // Task project.tasks[1]?.description; // null (caught by the inner handler) project.tasks[2]; // null (caught by the outer handler)
Type safety with co.loaded
You can tell your application how deeply your data is loaded by using the co.loaded
type.
The co.loaded
type is especially useful when passing data between components, because it allows TypeScript to check at compile time whether data your application depends is properly loaded. The second argument lets you pass a resolve
query to specify how deeply your data is loaded.
You can pass a resolve
query of any complexity to co.loaded
.
Manual subscriptions
If you have a CoValue's ID, you can subscribe to it anywhere in your code using CoValue.subscribe()
.
Note: Manual subscriptions are best suited for vanilla JavaScript — for example in server-side code or tests. Inside front-end components, we recommend using a subscription hook.
// Subscribe by ID const unsubscribe = Task.subscribe(taskId, {}, (updatedTask) => { console.log("Updated task:", updatedTask); }); // Always clean up when finished unsubscribe();
You can also subscribe to an existing CoValue instance using the $jazz.subscribe
method.
const myTask = Task.create({ title: 'My new task', }); // Subscribe using $jazz.subscribe const unsubscribe = myTask.$jazz.subscribe((updatedTask) => { console.log("Updated task:", updatedTask); }); // Always clean up when finished unsubscribe();
Ensuring data is loaded
In most cases, you'll have specified the depth of data you need in a resolve
query when you first load or subscribe to a CoValue. However, sometimes you might have a CoValue instance which is not loaded deeply enough, or you're not sure how deeply loaded it is. In this case, you need to make sure data is loaded before proceeding with an operation. The $jazz.ensureLoaded
method lets you guarantee that a CoValue and its referenced data are loaded to a specific depth (i.e. with nested references resolved):
async function completeAllTasks(projectId: string) { // Load the project const project = await Project.load(projectId, { resolve: true }); if (!project) return; // Ensure tasks are deeply loaded const loadedProject = await project.$jazz.ensureLoaded({ resolve: { tasks: { $each: true, }, }, }); // Now we can safely access and modify tasks loadedProject.tasks.forEach((task, i) => { task.$jazz.set("title", `Task ${i}`); }); }
This can be useful if you have a shallowly loaded CoValue instance, and would like to load its references deeply.
Best practices
- Load exactly what you need. Start shallow and add your nested references with care.
- Handle all three states up front. Treat
undefined
as loading,null
as not found or not accessible, and only render data for real values. - Use
$onError
at each level of your query that can fail.
- Never rely on data being present unless it is requested in your
resolve
query.