Skip to content

Lazy Models

Use lazyModel when a feature model lives in another module and should be loaded only when the application actually touches one of its units. This is useful for heavy screens, rarely opened flows, editor panels, chat tabs, and other code that should not be part of the first bundle.

The lazy object has the same shape as the real model. You can reference its units in rules before the module is loaded:

ts
import { event, lazyModel, reaction, store } from "@virentia/core";
import type { createChatModel } from "./chat.model";

const chat = lazyModel<ReturnType<typeof createChatModel>>(() =>
  import("./chat.model").then(({ createChatModel }) => createChatModel()),
);

const routeOpened = event<{ chatId: string }>();
const refreshRequested = event<{ chatId: string }>();

const currentChatId = store<string | null>(null);
const messageCount = store(0);

reaction({
  on: routeOpened,
  run({ chatId }) {
    void chat.opened({ chatId });
  },
});

reaction({
  on: refreshRequested,
  run({ chatId }) {
    void chat.loadHistoryFx(chatId);
  },
});

Here the current model describes what happened in the application, while the lazy model is loaded only when chat.opened or chat.loadHistoryFx is called. That keeps routing, commands, and background refreshes close to the current model without loading the chat code ahead of time.

You can also subscribe to lazy units before the module is loaded:

ts
reaction({
  on: chat.opened,
  run({ chatId }) {
    currentChatId.value = chatId;
  },
});

reaction({
  on: chat.loadHistoryFx.doneData,
  run(messages) {
    messageCount.value = messages.length;
  },
});

When a lazy unit is launched, Virentia pauses the placeholder branch, waits for the module, connects existing listeners to the real unit, and launches the real unit with the same payload and scope.

ts
await allSettled(chat.opened, {
  scope: appScope,
  payload: { chatId: "support" },
});

Loading state

Every lazy model exposes a pending store: true while the module is importing, false once it is loaded. It is per-scope (like an effect's pending), so each scope tracks its own loading.

ts
const loading = allSettled(chat.opened, {
  scope: appScope,
  payload: { chatId: "support" },
});

// chat.pending is `true` in appScope while ./chat.model imports
await loading;
// chat.pending is `false` again; the model is ready

Drive a spinner from chat.pending (for example via useUnit in React) instead of keeping your own loading flag for the import.

WARNING

Lazy models can wait for loading when an event, effect, or effect lifecycle unit is launched. Store reads stay synchronous: chat.messages.value cannot wait for an import.

Read lazy stores after the model has been loaded by an event or effect. For loading state before the module is ready, use pending; for anything more specific, keep that small shell state near the place that starts the scenario.