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
Tip

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

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

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.

No content available for tab:

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
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:

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:

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: '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.

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.$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 $isLoaded before accessing CoValue data. Use $jazz.loadingState for more detailed information.
  • Use $onError: 'catch' at each level of your query that can fail to handle inaccessible data gracefully.
  • Never rely on data being present unless it is requested in your resolve query.