Skip to content

Model

A model is an Effect service for one unit of application behavior: a screen, form, dialog, card, row, or headless coordinator. It owns the state and async work for that unit. React only renders the model’s public ui surface.

model make() -> stores + events + queries + child models -> public ports

Choose model boundaries by ownership. A task card owns task editing; a board owns the collection of cards; a session model owns authentication state. Avoid one global model that knows every screen.

import * as Effect from "effect/Effect";
import { Event, Model, Store } from "@unitflow/core";
export class CounterModel extends Model.Service<CounterModel>()(
"docs/counter",
)({
make: () => Effect.gen(function* () {
const count = Store.make(0);
const increment = yield* Event.input<number>().pipe(
Event.handler((amount) =>
Store.update(count, (current) => current + amount),
),
);
return {
inputs: { increment },
outputs: { count },
ui: { count, increment },
};
}),
}) {}

This class is both the model definition and its Effect service tag.

  • make runs once when this model instance is constructed.
  • count is private mutable state owned by the instance.
  • increment is the public action that changes it.
  • the returned sections are the only public contract.
  • CounterModel.layer supplies the model implementation to the application.

No hook or component is involved. Another model or a test can resolve the same contract with Model.get(CounterModel).

Every model returns inputs and outputs. Add ui when the model has a View.

Section Purpose Typical values
inputs Actions outside code may trigger Event.input, mutation run
outputs State and events outside code may observe stores, output events
ui The complete render surface stores, actions, child units

The sections are capability boundaries, not three copies of the same object.

Create a public input with Event.input, then attach the model-owned behavior:

const submit = yield* Event.input<FormData>().pipe(
Event.handler((form) => save(form)),
);

Inside make, submit is subscribe-only: the model handles the action but cannot emit its own public input. Code resolving the model receives the other side and can call Event.emit(model.inputs.submit, form).

Use Event.toInput(existingEvent) when republishing an existing full event as an input. Do not put a newly-created Event.make() directly in inputs; that would let the model drive its own public command port.

Outputs expose stores or events without exposing write capability:

outputs: {
count,
saved,
}

Parent models, tests, persistence, and analytics read these ports. The model still holds the full store internally, so it can update it.

View.make binds only ui:

ui: {
count,
increment,
}

A store becomes its current value, an event input becomes a callback, and a child unit remains a value that can be passed to the child’s View. Components do not need access to inputs or outputs.

A headless model simply omits ui:

return {
inputs: { refresh },
outputs: { session },
};

View.make rejects headless models, while other models can still resolve them with Model.get.

Additional sections are allowed when an output is meant for one audience:

return {
inputs: { submit },
outputs: { result },
ui: { formState, submit },
analytics: { submitted },
debug: { status },
};

They follow the read-only rules of outputs and are available through Model.get, but they are not passed to a View.

make returns a normal Effect. Resolve application services with yield* Service and other models with Model.get:

import * as Effect from "effect/Effect";
import { Event, Model, Query } from "@unitflow/core";
export class ProjectsPageModel extends Model.Service<ProjectsPageModel>()(
"docs/projects-page",
)({
make: () => Effect.gen(function* () {
const api = yield* ProjectsApi;
const session = yield* Model.get(SessionModel);
const projects = yield* Query.make({
stores: { user: session.outputs.currentUser },
handler: ({ user }) => api.listForUser(user.id),
});
return {
inputs: { refresh: Event.toInput(projects.refresh) },
outputs: { projects: projects.state },
ui: { projects: projects.state, refresh: projects.refresh },
};
}),
}) {}

The model’s layer declares those requirements. Compose their implementations at the application boundary:

import * as Layer from "effect/Layer";
const AppLayer = ProjectsPageModel.layer.pipe(
Layer.provideMerge(SessionModel.layer),
Layer.provideMerge(ProjectsApiLive),
);

The React runtime supplies the registry for the model graph. A headless Effect test provides Registry.layer explicitly.

Model.get intentionally exposes another model’s observation sections, not its individually-readable ui. The parent may pass the returned child unit through its own ui, but business composition should use the child’s outputs.

Tests can replace either kind of dependency. Regular Effect services use fake layers; models can use Model.layerValue(ChildModel, fakePorts).

The counter above is a singleton: there is one instance per registry. A model representing a repeated entity declares a key between the service id and its options:

interface TaskKey {
readonly id: string;
}
export class TaskModel extends Model.Service<TaskModel>()(
"docs/task",
)<TaskKey>()({
make: ({ id }) =>
Effect.gen(function* () {
const title = Store.make(`Task ${id}`);
const rename = yield* Event.input<string>().pipe(
Event.handler((nextTitle) => Store.set(title, nextTitle)),
);
return {
inputs: { rename },
outputs: { title },
ui: { title, rename },
};
}),
}) {}

Resolve it with its key:

const task = yield* Model.get(TaskModel, { id: "task-1" });

Within one registry, equal keys resolve the same instance and therefore the same state. Different keys produce independent instances. Valid keys include primitives, immutable plain-data records and arrays, and Effect Data values. Nested records are supported. Never mutate a key after using it.

Use a singleton for one application-level identity such as the current session. Use a keyed model when the identity comes from data: task id, account id, editor tab, route output, and similar values.

A parent leases a child with Model.get, reads its outputs, and may expose the child unit to its View:

export class TaskPanelModel extends Model.Service<TaskPanelModel>()(
"docs/task-panel",
)<TaskKey>()({
make: (key) => Effect.gen(function* () {
const task = yield* Model.get(TaskModel, key);
return {
inputs: {},
outputs: { title: task.outputs.title },
ui: { task },
};
}),
}) {}

TaskPanelModel and its child share the same task identity. The parent owns a lease on that child; disposing the parent releases it. In React, the parent View passes task to TaskView as its unit prop. JSX does not construct the child or decide its lifetime.

This keeps responsibilities explicit:

  • the child owns its state and actions;
  • the parent owns orchestration and child identity;
  • the View renders units the model graph already owns.

Use Model.list(ChildModel) when a parent owns a changing collection of keyed children: cards, rows, tabs, uploads, or inspectors.

export class BoardModel extends Model.Service<BoardModel>()("docs/board")({
make: () => Effect.gen(function* () {
const tasks = yield* Model.list(TaskModel);
yield* tasks.push({ id: "task-1" });
const taskTitles = tasks.select((task) => task.outputs.title);
const addTask = yield* Event.input<string>().pipe(
Event.handler((id) => Effect.asVoid(tasks.push({ id }))),
);
const removeTask = yield* Event.input<string>().pipe(
Event.handler((id) => tasks.remove({ id })),
);
return {
inputs: { addTask, removeTask },
outputs: { taskTitles },
ui: {
tasks: tasks.items,
addTask,
removeTask,
},
};
}),
}) {}
  • push(key) creates or reuses the keyed child and adds it to this list.
  • remove(key) releases this list’s ownership of the child.
  • items is the live store of child units for the View.
  • select combines one output store from every child.

See the complete task-board example for rendering, derived columns, and tests.

The model instance owns everything ongoing created during make: event handlers, queries, mutations, forwarding pipelines, child leases, and Effect finalizers. Disposal stops that work together.

This is why constructors such as Query.make, Mutation.make, Event.handler, and Store.forwardTo belong inside a model. Starting them in a top-level script or route middleware would create work with no model lifetime; the required InstanceScope is unavailable there, so it does not compile.

Singletons live until explicit disposal or registry shutdown. Keyed models stay cached for their idle TTL after the final lease is released. Lists can release children immediately. See Lifetime and Finalizers for TTL options, keepAlive, manual disposal, and cleanup hooks.

Sometimes a child needs an existing live store from the first line of make, not a copied value delivered later. A store reference is a valid model key:

import { Event, Model, Query, Store } from "@unitflow/core";
export class UserOrdersModel extends Model.Service<UserOrdersModel>()(
"docs/user-orders",
)<Store.Output<string>>()({
make: (currentUserId) =>
Effect.gen(function* () {
const orders = yield* Query.make({
stores: { currentUserId },
handler: ({ currentUserId }) => fetchOrders(currentUserId),
});
return {
inputs: { refresh: Event.toInput(orders.refresh) },
outputs: { orders: orders.state },
ui: { orders: orders.state, refresh: orders.refresh },
};
}),
}) {}
const session = yield* Model.get(SessionModel);
const orders = yield* Model.get(
UserOrdersModel,
session.outputs.currentUserId,
);

The store reference determines instance identity; changes to its value rerun the query inside that same instance. A different store reference creates a different instance.

An event reference is deliberately not a valid key: an event is an occurrence, not a current live value. Actions arriving after construction remain Event.input ports.

A model test resolves the model, emits the same public action as production, waits for owned reactive work to settle, and reads an output:

import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { Event, Model, Registry, Store } from "@unitflow/core";
const testLayer = CounterModel.layer.pipe(
Layer.provideMerge(Registry.layer),
);
it.effect("increments", () =>
Effect.gen(function* () {
const counter = yield* Model.get(CounterModel);
yield* Registry.allSettled(
Event.emit(counter.inputs.increment, 3),
);
assert.strictEqual(yield* Store.get(counter.outputs.count), 3);
}).pipe(Effect.provide(testLayer)),
);

Tests do not need React or a browser. See Testing for fake model layers, construction failures, and cleanup assertions.

Next: bind a model to React with View.make.