Overview
Unitflow brings UI behavior into the Effect runtime.
A Unitflow model is a UI-facing Effect Service. It owns state, actions, async work, child models, and lifetime. It can depend on other UI models and regular Effect services through layers. A View binds the model to the screen at the edge.
Model -> UI-facing Effect ServiceStore -> model stateEvent -> model actionQuery -> async read stateMutation -> async write stateView -> render bindingEffect already gives an application dependency injection, Layer, Scope,
Stream, fibers, schedules, typed errors, Schema, and test-friendly service
replacement. Unitflow keeps those tools available in UI state instead of
building a separate UI framework.
Unitflow adds only the pieces that UI models need:
- stores for renderable state
- events for model actions
- queries and mutations for visible async state
- a registry for keyed model instances and lifetime
- a React binding that renders a model’s
ui
Those pieces are Effect-idiomatic. Stores, events, queries, mutations, and
layers compose with .pipe(...), so model code reads like regular Effect code
instead of a separate UI DSL.
The result is UI state management that still feels like Effect architecture.
Unitflow is inspired by Effector: explicit stores, events, and UI logic outside the component tree, rebuilt around Effect services, layers, scopes, and dependency injection.
Why Not Effect Atom?
Section titled “Why Not Effect Atom?”Effect Atom is powerful, but Atom is a broad primitive: state, derived data,
async work, writes, and persistence can all become atoms, and the code
structure is still up to you. That much freedom is also hard on agents: without
an explicit shape, generated code tends to become messy and hard to review.
Unitflow is more
opinionated: it gives frontend code a model-first blueprint, where the UI unit
is an Effect service resolved through dependency injection and built from
smaller primitives like stores, events, queries, and mutations.
Composition Example
Section titled “Composition Example”The useful shape is not a giant model. It is a parent model that owns a flow, plus child models that own focused domains.
export class TaskModel extends Model.Service<TaskModel>()( "examples/task-board/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 }, }; }),}) {}export class BoardModel extends Model.Service<BoardModel>()( "examples/task-board/board",)({ make: Effect.gen(function* () { const draft = Store.make(""); const tasks = yield* Model.list(TaskModel);
yield* tasks.push({ id: "task-1" }); yield* tasks.push({ id: "task-2" });
const taskStates = tasks.select((task) => task.outputs.state); const boardState = Store.combine([draft, taskStates], (draft, taskStates) => ({ draft, taskStates, }));
const create = yield* Event.make<void>().pipe( Event.handler(() => Effect.gen(function* () { const title = (yield* Store.get(draft)).trim(); if (title === "") return;
const task = yield* tasks.push({ id: crypto.randomUUID() }); yield* Event.emit(task.inputs.rename, title); yield* Store.set(draft, ""); }), ), );
return { inputs: {}, outputs: { taskStates }, ui: { boardState, taskUnits: tasks.items, setDraft: Event.setter(draft), create, }, }; }),}) {}export const TaskCard = View.make(TaskModel, (task) => ( <article> <input value={task.state.title} onChange={(event) => task.rename(event.currentTarget.value)} /> </article>));
export const BoardView = View.make( BoardModel, ({ boardState, taskUnits, setDraft, create }) => ( <section> <input value={boardState.draft} onChange={(event) => setDraft(event.currentTarget.value)} /> <button type="button" onClick={() => create()}> Add </button>
{taskUnits.map((task) => ( <TaskCard key={task.key.id} unit={task} /> ))} </section> ),);it.effect("creates a task from the board draft", () => Effect.gen(function* () { const board = yield* Model.get(BoardModel);
yield* Registry.allSettled( Effect.gen(function* () { yield* Event.emit(board.ui.setDraft, "Write docs"); yield* Event.emit(board.ui.create); }), );
const tasks = yield* Store.get(board.outputs.taskStates); assert.strictEqual(tasks.at(-1)?.title, "Write docs"); }).pipe(Effect.provide(testLayer)),);TaskModel and BoardModel are services in the Effect graph. A test can
provide the real child model, replace it with Model.layerValue(...), or
provide fake API services that the model depends on. The View only renders the
model’s ui.
What This Buys
Section titled “What This Buys”- UI models compose with Effect dependency injection.
- Unitflow primitives compose with
.pipe(...)like Effect primitives. - Model state and async work live in the same Effect runtime as the rest of the app.
- Parent models can own child models or dynamic lists of child models.
- Tests can build a small layer, drive model actions, and assert model state.
- Lifecycle uses Effect scopes, so fibers, subscriptions, and finalizers close with the model instance.
Reading Order
Section titled “Reading Order”Start with the small primitives, then move into models and async work:
Stores -> state: make, get, set, derive, changedEvents -> actions: make, emit, handlerModel -> Effect service, contract, dependencies, childrenQueries -> async readsMutations -> async writesReact Binding -> render the model's uiTesting -> layers, fakes, allSettledStreams -> advanced pipelinesLifetime -> scopes, TTL, finalizers