Skip to content

Model

A Unitflow model is a UI-facing Effect service.

It describes one coherent piece of UI behavior: a screen, panel, form, card, row, dialog, or dynamically created item. The model owns durable state, actions, async work, child models, and lifecycle. Because it is an Effect service, it lives in the same dependency graph as API clients, KeyValueStore, configuration, clocks, schedulers, and other services.

That is the point of Unitflow’s dependency injection: UI behavior can depend on Effect services and on other UI models, and tests can replace either one with a small fake layer.

A model returns a public shape.

return {
inputs: {},
outputs: {},
ui: {},
};

Those sections decide how other code may use the model.

inputs -> write-only actions for parents, routes, persistence, and tests
outputs -> read-only state or events for parents, tests, and observers
ui -> render surface for a View (optional)

ui is optional: a headless model — a service other models resolve, with no screen of its own — returns only inputs/outputs. View.make accepts only models with a ui section (Model.Viewable), so binding a headless model to a View fails to compile.

inputs can be triggered, but not observed. Put actions here when outside code may tell the model to do something.

inputs: {
setQuery,
submit,
}

outputs can be read or subscribed to, but not written. Put values here when parent models, tests, analytics, or persistence code may observe them.

outputs: {
selectedProject,
results,
}

ui is what a View receives. Stores become current values, events become functions, and child models become units passed to child Views.

ui: {
searchState,
setQuery,
submit,
picker,
}

You can add extra read-only sections for a specific audience.

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

Extra sections are available through Model.get(...) and hidden from View.make(...). They follow the same read-only rule as outputs.

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.make<number>().pipe(
Event.handler((amount) =>
Store.update(count, (value) => value + amount),
),
);
return {
inputs: { increment },
outputs: { count },
ui: {
count,
onIncrement: increment,
},
};
}),
}) {}

The class has a .layer, so it composes like any other Effect service.

import * as Layer from "effect/Layer";
import { Registry } from "@unitflow/core";
const layer = CounterModel.layer.pipe(
Layer.provideMerge(Registry.layer),
);

Inside make, use normal Effect services with yield* Service.

const api = yield* ProjectApi;

Use Model.get(...) to depend on another UI model.

const currentUser = yield* Model.get(CurrentUserModel);

Both are provided through layers. Production can provide real services and real child models; tests can provide fake services or replace a child model with Model.layerValue(...).

const testLayer = ParentModel.layer.pipe(
Layer.provideMerge(Model.layerValue(CurrentUserModel, fakeUser)),
Layer.provideMerge(FakeProjectApi.layer),
Layer.provideMerge(Registry.layer),
);

Singleton models omit a key. Keyed models add <Key>().

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}`);
return {
inputs: {},
outputs: { title },
ui: { title },
};
}),
}) {}

The same key resolves the same model instance. Keep keys small and stable: primitive values, flat plain-data records, or Effect Data values.

A child model is just another model in the Effect graph. The child owns one piece of UI behavior; the parent resolves that child and talks to its public sections.

ProjectPickerModel below owns project selection. The parent does not reach into its local stores or handlers. It resolves the picker, listens to changes in picker.outputs.selectedProject, and passes the child unit to the View through ui.

import * as Effect from "effect/Effect";
import { Event, Model, Store } from "@unitflow/core";
export type ProjectId = string;
interface Project {
readonly id: ProjectId;
readonly name: string;
}
interface ProjectPickerKey {
readonly id: string;
}
export class ProjectPickerModel extends Model.Service<ProjectPickerModel>()(
"docs/project-picker",
)<ProjectPickerKey>()({
make: Effect.gen(function* () {
const projects = Store.make<ReadonlyArray<Project>>([
{ id: "p1", name: "Lobby refresh" },
{ id: "p2", name: "Mobile checkout" },
]);
const selectedProject = Store.make<ProjectId | null>(null);
const select = yield* Event.make<ProjectId>().pipe(
Event.handler((projectId) => Store.set(selectedProject, projectId)),
);
return {
inputs: { select },
outputs: { selectedProject },
ui: {
projects,
selectedProject,
onSelect: select,
},
};
}),
}) {}

The parent View passes picker to the child View. JSX does not need to know how the child is built.

Use Model.list(ChildModel) when the parent owns a dynamic collection of child models: kanban cards, tabs, uploads, rows, inspectors, and similar UI pieces.

In a board, each task model owns one card. The board model owns the list of cards, creates and removes cards, and derives aggregate board state from the children.

import * as Effect from "effect/Effect";
import { Event, Model, Store } from "@unitflow/core";
export type TaskStatus = "todo" | "doing" | "done";
export interface TaskKey {
readonly id: string;
}
export interface TaskState {
readonly id: string;
readonly title: string;
readonly status: TaskStatus;
}
const initialTask = (id: string): TaskState => ({
id,
title: "New task",
status: "todo",
});
export class TaskModel extends Model.Service<TaskModel>()(
"docs/kanban/task",
)<TaskKey>()({
make: ({ id }) =>
Effect.gen(function* () {
const state = Store.make<TaskState>(initialTask(id));
const rename = yield* Event.make<string>().pipe(
Event.handler((title) =>
Store.update(state, (task) => ({
...task,
title: title.trim() === "" ? task.title : title,
})),
),
);
const move = yield* Event.make<TaskStatus>().pipe(
Event.handler((status) =>
Store.update(state, (task) => ({ ...task, status })),
),
);
return {
inputs: { rename, move },
outputs: { state },
ui: { state, rename, move },
};
}),
}) {}

tasks.items is the live store of child units that a board View passes to task Views. Each item includes the list key as task.key, so a task keyed by { id: "task-1" } can render with key={task.key.id}. tasks.select(...) creates one store from the same output in each child. tasks.push(...) creates or reuses a child by key, and tasks.remove(...) removes that child from this list.