composable_architecture_core

A pure Dart library for building applications using the Composable Architecture pattern. Inspired by Point-Free's TCA, it provides a unidirectional data flow with composable reducers, structured effects, and first-class testability.

Zero dependencies. Works in any Dart environment - Flutter, server-side, CLI, or standalone.

Core Concepts

Store

The runtime that holds your application state, processes actions through a reducer, and executes effects:

final store = Store.emptyEnvironment(
  0,                // initial state
  counterReducer,   // reducer
);

store.send(CounterAction.increment);
print(store.state); // 1

For reducers that need dependencies (API clients, databases, etc.), use Store.initial:

final store = Store.initial(
  AppState(),
  appReducer,
  AppEnvironment(apiClient: ApiClient()),
);

Reducer

A pure function that takes the current state, an action, and an environment, then returns the new state and any effects to execute:

final counterReducer = Reducer<int, CounterAction, EmptyEnvironment>.transform(
  (state, action, env) => switch (action) {
    CounterAction.increment => state + 1,
    CounterAction.decrement => state - 1,
    CounterAction.reset => 0,
  },
);

When you need to perform side effects, return both state and effects:

final userReducer = Reducer<UserState, UserAction, UserEnvironment>(
  reduce: (state, action, env) => switch (action) {
    LoadUser() => (
      state: state.copyWith(isLoading: true),
      effect: Effect.future(() => env.api.fetchUser())
        .map((user) => UserAction.userLoaded(user)),
    ),
    UserLoaded(:final user) => (
      state: state.copyWith(isLoading: false, user: user),
      effect: Effect.none(),
    ),
  },
);

Reducer Factories

Factory Description
Reducer.transform(fn) State transformation, no effects
Reducer.emit(fn) Effect emission, no state change
Reducer.combine([...]) Merges multiple reducers
Reducer.empty() No-op reducer

Effect

A description of a unit of work. Effects are values - they describe what to do, not how to do it. The store handles execution.

// Async work
Effect.future(() async => await api.fetchData())

// Synchronous value
Effect.value(SomeAction.loaded(data))

// No work
Effect.none()

// Compose effects
Effect.merge([effect1, effect2])      // run concurrently
Effect.concatenate([effect1, effect2]) // run sequentially

// Fire and forget (run but ignore result)
effect.fireAndForget()

// Cancellation
effect.cancellable(id: "search", cancelInFlight: true)

// Timing
effect.debounce(id: "search", interval: Duration(milliseconds: 300))
effect.throttle(id: "scroll", interval: Duration(milliseconds: 100))
effect.delay(Duration(seconds: 1))

// Composition
effect.map((value) => SomeAction.loaded(value))
effect.flatMap((value) => Effect.future(() => transform(value)))

Effect Types

Type Description
Effect.none() No-op
Effect.value(v) Emit a synchronous value
Effect.future(fn) Async computation
Effect.stream(stream) Bridge a Dart Stream
Effect.run(fn) Synchronous computation with error handling
Effect.merge([...]) Concurrent execution
Effect.concatenate([...]) Sequential execution
Effect.periodic(interval, fn) Emit on fixed interval
Effect.cancel(id) Cancel an in-flight effect

Composition

The key feature: building large applications from small, isolated modules.

Pullback

Lift a local reducer to work within a larger state/action domain using lenses:

// Define lenses for state and action mapping
Lens<AppState, CounterState> counterStateLens = (
  get: (appState) => appState.counter,
  set: (appState, counter) => appState.copyWith(counter: counter),
);

ActionLens<AppAction, CounterAction> counterActionLens = (
  extract: (appAction) => switch (appAction) {
    AppAction.counter(action) => action,
    _ => null,
  },
  embed: (counterAction) => AppAction.counter(counterAction),
);

// Pullback the local reducer to the global domain
final appReducer = counterReducer.pullback(
  stateLens: counterStateLens,
  actionLens: counterActionLens,
);

Combine

Merge multiple reducers for the same state/action type:

final appReducer = Reducer.combine([
  featureAReducer.pullback(...),
  featureBReducer.pullback(...),
  globalReducer,
]);

ForEach

Apply a reducer to each element in a collection:

final listReducer = itemReducer.forEach(
  stateLens: itemsLens,
  actionPrism: itemPrism,
  toID: (item) => item.id,
  toLocalEnvironment: (_, env) => env,
);

Store Scoping

Create child stores that project a subset of state and actions:

final counterStore = appStore.scope(
  toLocalState: (appState) => appState.counter,
  toGlobalAction: (counterAction) => AppAction.counter(counterAction),
);

Optics

Type-safe accessors for decomposing state and actions:

// Lens: bidirectional state access
typedef Lens<GlobalState, LocalState> = ({
  LocalState Function(GlobalState) get,
  GlobalState Function(GlobalState, LocalState) set,
});

// ActionLens: action filtering and embedding
typedef ActionLens<GlobalAction, LocalAction> = ({
  LocalAction? Function(GlobalAction) extract,
  GlobalAction Function(LocalAction) embed,
});

// Prism: for keyed collections
typedef Prism<GlobalAction, LocalAction, ID> = ({
  (ID, LocalAction)? Function(GlobalAction) extract,
  GlobalAction Function(ID, LocalAction) embed,
});

Observable

A lightweight reactive primitive powering Store's state and action streams:

store.stateObservable
  .map((state) => state.count)
  .distinct()
  .when((count) => count > 0)
  .listen((count) => print('Positive count: $count'));

Operators: map, distinct, when (filter).

CurrentValueSubject<T> is the concrete implementation - an observable that holds and emits its current value.

Testing

TestStore records all state changes and dispatched actions for easy assertions:

final testStore = TestStore(
  0,                // initial state
  counterReducer,   // reducer
  null,             // environment
);

testStore.send(CounterAction.increment);
expect(testStore.states, [0, 1]);
expect(testStore.actions, [CounterAction.increment]);

// Assert expected state inline
testStore.send(
  CounterAction.increment,
  expected: (state) => expect(state, 2),
);

// Test async sequences with timing
await testStore.sendSequence([
  (CounterAction.increment, Duration(milliseconds: 100)),
  (CounterAction.increment, Duration(milliseconds: 100)),
]);

Data Flow

store.send(action)
    |
    v
ActionBuffer (queues nested sends)
    |
    v
Reducer: (state, action, env) -> (newState, effect)
    |               |
    v               v
Update state    Execute effect
    |               |
    v               v
Notify          emit(action) -> re-enters send()
observers       register/dispose/guard cancellables

Installation

dependencies:
  composable_architecture_core: ^0.1.0

Requirements

  • Dart SDK >= 3.0.6

Libraries

composable_architecture_core
A pure Dart library for building applications using the Composable Architecture pattern.