Context-based unidirectional state management for SwiftUI, built on Swift Concurrency.
dependencies: [
.package(url: "https://github.com/den-ree/chiui", from: "1.1.0")
]- Unidirectional flow.
Store -> ViewModel -> State -> View -> Action -> ViewModel -> Store. - Pure reducer.
respond(to:state:)is aclass func— deterministic, testable without mocks. - Explicit effects. All async work (store writes, network, navigation) lives in
handle(_:). - One observable surface. The view model exposes a single
stateproperty; everything else is@ObservationIgnored. - No implicit chaining. Async flow is expressed with ordinary
await, not closures or.then.
| Symbol | Role |
|---|---|
ContextualStore<State> |
Actor-isolated source of truth. |
StoreContext |
DI container owning the store + coordinator + services. |
ContextViewModel<Context, State, Action, Effect> |
@Observable view model. |
ContextualAction |
Action contract (Equatable, Sendable) that includes .storeChanged(StoreState). |
send(_:) -> Task<Void, Never>? |
Sync entry for actions; returns a task only when handle runs. |
respond(to:state:) -> Effect? |
Pure sync reducer. Mutates inout State, returns optional Effect. |
handle(_:) async |
Executes async side effects emitted by the reducer. |
updateStore(_:) async |
Atomic mutation of store state (runs inside the store actor). |
ContextualView |
SwiftUI view with state + bindTo(_:action:) helpers. |
struct EntryStoreState: ContextualStoreState {
var entries: [Entry] = []
var selectedId: Entry.ID?
}
struct EntryState: ContextualViewState {
var title: String = ""
var isSaving: Bool = false
init() {}
}final class EntryViewModel: ContextViewModel<EntryContext, EntryState, EntryViewModel.Action, EntryViewModel.Effect> {
enum Action: ContextualAction {
case storeChanged(EntryStoreState)
case titleChanged(String)
case saveTapped
case saved
}
enum Effect {
case persistTitle(String)
case save(title: String)
}
override class func respond(to action: Action, state: inout EntryState) -> Effect? {
switch action {
case .storeChanged(let store):
state.title = store.entries.first { $0.id == store.selectedId }?.title ?? ""
state.isSaving = false
return nil
case .titleChanged(let title):
state.title = title
return .persistTitle(title)
case .saveTapped:
state.isSaving = true
return .save(title: state.title)
case .saved:
state.isSaving = false
return nil
}
}
override func handle(_ effect: Effect) async {
switch effect {
case .persistTitle(let title):
await updateStore { $0.draftTitle = title }
case .save(let title):
try? await api.save(title)
await updateStore { $0.entries.append(Entry(title: title)) }
send(.saved)
}
}
}struct EntryView: ContextualView {
@State var viewModel: EntryViewModel
init(_ context: EntryContext) {
_viewModel = .init(initialValue: .init(context))
}
var body: some View {
Form {
TextField("Title", text: bindTo(\.title) { .titleChanged($0) })
Button("Save") {
send(.saveTapped)
}
.disabled(state.isSaving)
}
}
}import Testing
@testable import App
@Suite("EntryViewModel tests")
struct EntryViewModelTests {
@Test("Reducer: titleChanged updates state and emits effect")
func reducer_titleChanged_updatesState_andEmitsEffect() {
var state = EntryState()
let effect = EntryViewModel.respond(to: .titleChanged("Hello"), state: &state)
#expect(state.title == "Hello")
#expect(effect == .persistTitle("Hello"))
}
@Test("send(saveTapped) runs effect and updates store")
@MainActor
func send_saveTapped_runsEffectAndUpdatesStore() async {
let context = EntryContext(initialState: .init(), api: .mockSuccess)
let sut = EntryViewModel(context)
_ = sut.send(.titleChanged("New entry"))
if let task = sut.send(.saveTapped) {
await task.value
}
let store = await context.store.state
#expect(store.entries.last?.title == "New entry")
#expect(sut.state.isSaving == false)
}
}respondis pure. It's aclass funcwith noself. It cannot touch the store, coordinator, or any service.- All side effects live in
handle. Store writes go throughawait updateStore { ... }. - Only
stateis observable. Mark non-state stored properties on your view model with@ObservationIgnored. - Context owns dependencies. Coordinator, services, clients all live on the
StoreContext, not the view model. - Views dispatch, never mutate. Call
send(...)(orviewModel.send(...)) from actions and bindings; onlyawaitthe returned task when you must wait for async effect completion.
- iOS 17.0+
- macOS 14.0+
- Swift 5.9+ / Xcode 15.0+
Full API reference: den-ree.github.io/chiui/documentation/chiui.
See CONTRIBUTING.md.
MIT. See LICENSE for details.