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
Jazz automatically deduplicates loading. If you subscribe to the same CoValue multiple times in your app, Jazz will only fetch it once. That means you don’t need to deeply load a CoValue just in case a child component might need its data, and you don’t have to worry about tracking every possible field your app needs in a top-level query. Instead, pass the CoValue ID to the child component and subscribe there — Jazz will only load what that component actually needs.
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 useCoState hook allows you to reactively subscribe to CoValues in your React 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
useAccount is similar to useCoState, 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 be either:
- Loaded → The CoValue has been successfully loaded and all its data is available
- Not Loaded → The CoValue is not yet available
You can use the $isLoaded field to check whether a CoValue is loaded. For more detailed information about why a CoValue is not loaded, you can check $jazz.loadingState:
"loading"→ The CoValue is still being fetched"unauthorized"→ The current user doesn't have permission to access this CoValue"unavailable"→ The CoValue couldn't be found or an error (e.g. a network timeout) occurred while loading
See 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(), get subtasks() { return co.list(Task); }, }); const Project = co.map({ name: z.string(), tasks: co.list(Task), }); const project = await Project.load(projectId); if (!project.$isLoaded) 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; // MaybeLoaded<ListOfTasks> const projectWithTasksShallow = await Project.load(projectId, { resolve: { tasks: true, }, }); if (!projectWithTasksShallow.$isLoaded) 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]; // MaybeLoaded<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.$isLoaded) throw new Error("Project not found or not accessible"); // The task will be loaded projectWithTasks.tasks[0]; // Task // Primitive fields are always loaded projectWithTasks.tasks[0].title; // string // References on the Task may not be loaded projectWithTasks.tasks[0].subtasks; // MaybeLoaded<ListOfTasks> // CoTexts are CoValues too projectWithTasks.tasks[0].description; // MaybeLoaded<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.$isLoaded) throw new Error("Project not found or not accessible"); // Primitive fields are always loaded projectDeep.tasks[0].subtasks[0].title; // string // The description will be loaded as well projectDeep.tasks[0].description; // CoPlainText
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.
You can also specify resolve queries at the schema level, using the .resolved() method. These queries will be used when loading CoValues from that schema (if no resolve query is provided by the user) and in types defined with co.loaded.
const TaskWithDescription = Task.resolved({ description: true, }); const ProjectWithTasks = Project.resolved({ tasks: { // Use `.resolveQuery` to get the resolve query from a schema and compose it in other queries $each: TaskWithDescription.resolveQuery, } }); // .load() will use the resolve query from the schema const project = await ProjectWithTasks.load(projectId); if (!project.$isLoaded) throw new Error("Project not found or not accessible"); // Both the tasks and the descriptions are loaded project.tasks[0].description; // CoPlainText
Loading Errors
A load operation will be successful only if all references requested (both optional and required) could be successfully loaded. If any reference cannot be loaded, the entire load operation will return a not-loaded CoValue to avoid potential inconsistencies.
// If permissions on description are restricted: const task = await Task.load(taskId, { resolve: { description: true }, }); task.$isLoaded; // false task.$jazz.loadingState; // "unauthorized"
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 projectWithUnauthorizedTasks = await Project.load(projectId, { resolve: { tasks: { $each: true } }, }); project.$isLoaded; // false project.$jazz.loadingState; // "unauthorized"
Loading will be successful if all requested references are loaded. Non-requested references may or may not be available.
// One task in the list has restricted permissions const shallowlyLoadedProjectWithUnauthorizedTasks = await Project.load( projectId, { resolve: true, }, ); if (!project.$isLoaded) 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.$isLoaded; // true // Tasks may not be loaded since we didn't request them project.tasks.$isLoaded; // may be false
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: 'catch'. Inaccessible items will be not-loaded CoValues, while accessible items load properly.
// Inaccessible tasks will not be loaded, but the project will const projectWithInaccessibleSkipped = await Project.load(projectId, { resolve: { tasks: { $each: { $onError: "catch" } } }, }); if (!project.$isLoaded) { throw new Error("Project not found or not accessible"); } if (!project.tasks.$isLoaded) { throw new Error("Task List not found or not accessible"); } project.tasks[0].$isLoaded; // true project.tasks[1].$isLoaded; // true project.tasks[2].$isLoaded; // false (caught by $onError)
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.
// Inaccessible tasks will not be loaded, but the project will const projectWithNestedInaccessibleSkipped = await Project.load(projectId, { resolve: { tasks: { $each: { description: true, $onError: "catch", }, }, }, }); if (!project.$isLoaded) { throw new Error("Project not found or not accessible"); } project.tasks[0].$isLoaded; // true project.tasks[1].$isLoaded; // true project.tasks[2].$isLoaded; // false (caught by $onError)
We can fix this by adding handlers at both levels
const projectWithMultipleCatches = await Project.load(projectId, { resolve: { tasks: { $each: { description: { $onError: "catch" }, // catch errors loading task descriptions $onError: "catch", // catch errors loading tasks too }, }, }, }); project.$isLoaded; // true project.tasks[0].$isLoaded; // true project.tasks[0].description.$isLoaded; // true project.tasks[1].$isLoaded; // true project.tasks[1].description.$isLoaded; // false (caught by the inner handler) project.tasks[2].$isLoaded; // false (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();
Selectors
Sometimes, you only need to react to changes in specific parts of a CoValue. In those cases, you can provide a select function to specify what data you are interested in,
and an optional equalityFn option to control re-renders.
select: extract the fields you care aboutequalityFn: (optional) control when data should be considered equal
function ProjectViewWithSelector({ projectId }: { projectId: string }) { // Subscribe to a project const project = useCoState(Project, projectId, { resolve: { tasks: true, }, select: (project) => { if (!project.$isLoaded) return project.$jazz.loadingState; return { name: project.name, taskCount: project.tasks.length, }; }, // Only re-render if the name or the number of tasks change equalityFn: (a, b) => { if (typeof a === "string" || typeof b === "string") { // If either value is a string, it is not loaded, so we cannot check for equality. return false; } return a?.name === b?.name && a?.taskCount === b?.taskCount; }, }); if (typeof project === "string") { switch (project) { case "unauthorized": return "Project not accessible"; case "unavailable": return "Project not found"; case "loading": return "Loading..."; } } return ( <div> <h1>{project.name}</h1> <small>{project.taskCount} task(s)</small> </div> ); }
By default, the return values of the select function will be compared using Object.is, but you can use the equalityFn to add your own logic.
You can also use useAccount in the same way, to subscribe to only the changes in a user's account you are interested in.
import { useAccount } from "jazz-tools/react"; function ProfileName() { // Only re-renders when the profile name changes const profileName = useAccount(MyAppAccount, { resolve: { profile: true, }, select: (account) => account.$isLoaded ? account.profile.name : "Loading...", }); return <div>{profileName}</div>; }
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.$isLoaded) 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.
- Always check
$isLoadedbefore accessing CoValue data. Use$jazz.loadingStatefor more detailed information. - Use
$onError: 'catch'at each level of your query that can fail to handle inaccessible data gracefully.
- Use selectors and an
equalityFnto prevent unnecessary re-renders.
- Never rely on data being present unless it is requested in your
resolvequery.