Skip to content

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 Service
Store -> model state
Event -> model action
Query -> async read state
Mutation -> async write state
View -> render binding

Effect 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.

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.

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 },
};
}),
}) {}

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.

  • 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.

Start with the small primitives, then move into models and async work:

Stores -> state: make, get, set, derive, changed
Events -> actions: make, emit, handler
Model -> Effect service, contract, dependencies, children
Queries -> async reads
Mutations -> async writes
React Binding -> render the model's ui
Testing -> layers, fakes, allSettled
Streams -> advanced pipelines
Lifetime -> scopes, TTL, finalizers