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:
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:
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.
await scoped(appScope, () => chat.opened({ 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.
const loading = scoped(appScope, () => chat.opened({ chatId: "support" }));
// chat.pending is `true` in appScope while ./chat.model imports
await loading;
// chat.pending is `false` again; the model is readyDrive 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. Gate a defensive read on loaded, not on pending — see below.
loaded vs pending
The two flags answer different questions, and only one of them can gate a read:
| before load | while loading | after load | |
|---|---|---|---|
pending | false | true | false |
loaded | false | false | true |
pending means "an import is in flight", so it reads false both before and after — it cannot tell "not loaded yet" from "ready". loaded is the flag that can:
const unreadCount = computed(() => (chat.loaded.value ? chat.unread.value : 0));Reading a lazy store while loaded is false throws Lazy unit is not loaded yet. The type does not stop you: LazyModel<Model> is typed as Model, so chat.unread.value looks like a plain value at compile time.
Like pending, loaded is per-scope: a scope learns the model is loaded once it has taken part in a load, which is what happens the first time it calls any lazy unit.