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 portsChoose 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.
A Complete Small Model
Section titled “A Complete Small Model”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.
makeruns once when this model instance is constructed.countis private mutable state owned by the instance.incrementis the public action that changes it.- the returned sections are the only public contract.
CounterModel.layersupplies 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).
The Public Contract
Section titled “The Public Contract”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.
Inputs are actions
Section titled “Inputs are actions”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 are observations
Section titled “Outputs are observations”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.
UI is the render contract
Section titled “UI is the render contract”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.
Audience-specific outputs
Section titled “Audience-specific outputs”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.
Dependencies and Layers
Section titled “Dependencies and Layers”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).
Singleton and Keyed Models
Section titled “Singleton and Keyed Models”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.
Compose Parent and Child Models
Section titled “Compose Parent and Child Models”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.
Own Dynamic Children with Model.list
Section titled “Own Dynamic Children with Model.list”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.itemsis the live store of child units for the View.selectcombines one output store from every child.
See the complete task-board example for rendering, derived columns, and tests.
Model Lifetime Owns Reactive Work
Section titled “Model Lifetime Owns Reactive Work”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.
Advanced: Key on a Live Store
Section titled “Advanced: Key on a Live Store”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.
Test Through the Public Contract
Section titled “Test Through the Public Contract”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.