Composition is a lightweight Swift framework for building composable app architecture. It provides a protocol-based system for managing state transitions, side effects, and parent-child composition using async/await.
The framework is intentionally minimal: no macros, no code generation, and zero external dependencies. The entire core fits in a handful of files. It builds on Swift's native concurrency (async/await, @MainActor, @TaskLocal) and the Observation framework (@Observable).
Composition ships with CompositionTesting, a companion module that provides TestStore for deterministic, step-by-step verification of state transitions, triggers, and re-entrant effects.
Composition
- Protocol-based architecture with the
Composableprotocol - Parent-child composition via
mapAction(_:)delegate forwarding @dynamicMemberLookupfor natural property access on stores
Reactivity
- Struct-based state with value semantics
- Enum-driven actions for exhaustive state transitions
- Reactive
Triggersystem that fires actions on state changes - Native SwiftUI and Observation framework integration
Deterministic Testing
TestStorefor step-by-step verification — send, set, resume, scope
The following examples demonstrate the core concepts using a simple counter. A full Todo app is available in the Examples directory.
A store conforms to the Composable protocol, declaring its State, Action, and reduce(_:) method:
import Composition
import Observation
@MainActor @Observable
final class Counter: Composable {
struct State {
var count = 0
}
enum Action {
case incrementButtonTapped
case decrementButtonTapped
}
var state: State
let delegate: @MainActor (Action) async -> Void
init(state: State = State(), delegate: @escaping @MainActor (Action) async -> Void = { _ in }) {
self.state = state
self.delegate = delegate
}
func reduce(_ action: Action) async {
switch action {
case .incrementButtonTapped:
state.count += 1
case .decrementButtonTapped:
state.count -= 1
}
}
}Use mapAction(_:) to connect a child store's actions to a parent:
@MainActor @Observable
final class Parent: Composable {
enum Action {
case child(Child.Action)
}
var state = ()
let delegate: @MainActor (Action) async -> Void = { _ in }
lazy var child = Child(delegate: mapAction { .child($0) })
func reduce(_ action: Action) async {
switch action {
case .child:
// Handle child actions at the parent level if needed
return
}
}
}Triggers automatically dispatch actions when observed state values change:
@MainActor @Observable
final class Counter: Composable {
struct State {
var count = 0
var isEven = true
}
enum Action {
case incrementButtonTapped
case countDidChange
}
var state: State
@ObservationIgnored lazy var triggers: [Trigger<State, Action>] = [
trigger(.countDidChange, observing: \.count),
]
// ...
func reduce(_ action: Action) async {
switch action {
case .incrementButtonTapped:
state.count += 1
case .countDidChange:
state.isEven = state.count.isMultiple(of: 2)
}
}
}Stores integrate naturally with SwiftUI using @State and @Bindable:
import SwiftUI
struct CounterView: View {
@State private var store = Counter()
var body: some View {
VStack {
// Dynamic member lookup: store.count instead of store.state.count
Text("Count: \(store.count)")
HStack {
Button("-") {
Task { await store.send(.decrementButtonTapped) }
}
Button("+") {
Task { await store.send(.incrementButtonTapped) }
}
}
}
}
}For two-way bindings, use @Bindable:
struct TodoInputView: View {
@Bindable var store: TodoInput
var body: some View {
TextField("Title", text: $store.title)
}
}TestStore from CompositionTesting provides deterministic control over state transitions:
import CompositionTesting
import Testing
struct CounterTests {
@Test @MainActor
func increment() async {
let store = TestStore { Counter() }
await store.send(.incrementButtonTapped)
// Dynamic member lookup works on TestStore too
#expect(store.count == 1)
}
}When actions produce re-entrant effects or trigger cascading actions, use resume() to step through each one:
@Test @MainActor
func addingTodoFiresTrigger() async {
let store = TestStore { TodoList() }
await store.send(.newTodoButtonTapped)
// Use scope(to:) to interact with child stores
await store.scope(to: \.input)?.set(\.title, to: "Buy milk")
await store.scope(to: \.input)?.send(.addButtonTapped)
// Resume the re-entrant action dispatched via mapAction delegate
await store.resume()
#expect(store.todos.map(\.title) == ["Buy milk"])
// Resume the trigger fired by todos.count changing
await store.resume()
}TestStore captures the task-local values bound at its creation, and every action processed through send, set, and resume — including re-entrant sends and trigger cascades — runs with those values. This makes task-local values a natural seam for injecting test doubles: bind them once around the store's creation, with no need to wrap each send:
enum FetchTodos {
@TaskLocal static var current: () async -> [Todo] = { [] }
}
@Test @MainActor
func fetchesTodos() async {
let store = FetchTodos.$current.withValue({ [Todo(title: "Buy milk")] }) {
TestStore { TodoList() }
}
// The value bound at store creation is visible here, even though this
// send is outside the withValue scope. Task-local values bound around
// an individual send do not propagate into action execution.
await store.send(.refreshButtonTapped)
}| Minimum Version | |
|---|---|
| Swift | 6.2 |
| Xcode | 26.0 |
| iOS | 26.0 |
| macOS | 26.0 |
| watchOS | 26.0 |
| tvOS | 26.0 |
| visionOS | 26.0 |
Add the package dependency to your Package.swift:
dependencies: [
.package(url: "https://github.com/<owner>/swift-composition.git", from: "0.1.0"),
]Then add the products to your targets:
.target(
name: "YourApp",
dependencies: [
.product(name: "Composition", package: "swift-composition"),
]
),
.testTarget(
name: "YourAppTests",
dependencies: [
.product(name: "CompositionTesting", package: "swift-composition"),
]
),Or in Xcode, go to File > Add Package Dependencies and paste the repository URL.
For more in-depth infomation, see API Documentation.
For a complete working example, see the Todo app in the Examples directory.
This library does not collect or track user information, so it does not include a PrivacyInfo.xcprivacy file.
This project is released under the MIT License. See LICENSE for details.