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

No content available for tab:

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

No content available for tab:

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 CoValue
  • null → 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
Always load data explicitly

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.

No content available for tab:

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:

TaskUser 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.

No content available for tab:

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.