Reactor is a lightweight Swift architecture for predictable view models. A view reads observable state and sends events; the reactor owns every state change and side effect.
View -> Action -> reduce -> State
Side effect or publisher -> Mutation -> reduce -> State
View -> Destination -> navigation
- Swift 6.3+
- iOS 13+ or macOS 11+
- iOS 17+ or macOS 14+ for the Observation-based SwiftUI APIs shown below
Add the package and the Reactor product to your Swift target:
dependencies:[.package(
url:"https://github.com/plajdo/refactor.git",
from:"3.0.0")].target(
name:"MyApp",
dependencies:[.product(name:"Reactor",package:"refactor")])State is the complete UI snapshot and the single source of truth. Views send Action and Destination events; asynchronous results and external events return as Mutation values.
import Observation
import Reactor
@ObservablefinalclassItemsViewModel:Reactor{typealiasEvent=Reactor::Event<Action,Mutation,Destination>
// MARK: - Dependencies
@ObservationIgnoredprivateletitemService=ItemService()
// MARK: - Properties
vardestination:Destination?
// MARK: - Action
enumAction:Sendable{case load
}
// MARK: - Mutation
enumMutation:Sendable{}
// MARK: - Destination
enumDestination:Sendable{case detail(Item.ID)}
// MARK: - State
@MainActor@ObservablefinalclassState{varitemsFetchingState:DataFetchingState<[Item],ItemError>=.idle
varitems:[Item]{return itemsFetchingState.successValue ??[]}}
// MARK: - Lifecycle
func makeInitialState()->State{returnState()}
// MARK: - Reduce
func reduce(state:inoutState, event:Event){switch event.kind {case.action(.load):fetch(event, \.itemsFetchingState){returntryawait itemService.fetchItems()}case.mutation,.destination:break}}}Keep event payloads Sendable. Apply synchronous changes directly in reduce, and never mutate state from the view or an asynchronous closure.
Store the reactor with @ViewModel, read its state directly, and translate every interaction into an event. Call start() once to activate subscriptions declared in transform().
import Reactor
import SwiftUI
structItemsView:View{@ViewModelprivatevarviewModel=ItemsViewModel()varbody:someView{List(viewModel.items){ item inButton(item.name){
viewModel.send(destination:.detail(item.id))}}.overlay{if viewModel.itemsFetchingState.isLoading {ProgressView()}}.task{
viewModel.start()await viewModel.send(action:.load)}}}Use send(action:) to dispatch without waiting. Use await send(action:) when the caller must wait for side effects tracked by that event, for example in .refreshable or a test.
Two-way controls remain reducer-driven through bind:
TextField("Search",
text: viewModel.bind(\.query, action:ItemsViewModel.Action.didChangeQuery))Use fetch when one operation maps to a DataFetchingState. Use run when the result needs a mutation and additional reducer logic. Snapshot state before starting either operation—do not capture state across an await.
case .action(.load):letitemID= state.itemID
fetch(event, \.itemFetchingState){()throws(ItemError)inreturntryawait itemService.fetchItem(id: itemID)}
case .action(.didTapSave):letdraft= state.draft
run(event){do{return.didFinishSaving(tryawait itemService.save(draft))}catch{return.didFailSaving
}}
case .mutation(.didFinishSaving(let item)):
state.savedItem = item
case .mutation(.didFailSaving):
state.isShowingSaveError =truefetch manages .loading, .success, .failure, and cancellation automatically. Independent operations run concurrently, so use separate fetching-state properties for independent requests.
Observe cross-feature or service events with Reactor publishers. Keep transform() free of side effects other than subscribe calls.
func transform(){subscribe(
to:{ itemEventService.updatesPublisher },
map:{.didReceiveUpdate($0)})}Use PassthroughPublisher for discrete events delivered only to active subscribers. Use @Broadcast(replayLastValue: true) when a new subscriber should receive the latest emitted value immediately.
Use AnyReactor<Action, Destination, State> when a view should accept multiple view-model implementations with the same public contract. It forwards to the original reactor and does not copy its state.
typealiasItemsReactor=AnyReactor<ItemsViewModel.Action,ItemsViewModel.Destination,ItemsViewModel.State>letviewModel:ItemsReactor=ItemsViewModel().eraseToAnyReactor()Use Stub for deterministic preview states without production dependencies or side effects. Create a fresh state for each preview.
letpreviewViewModel=Stub<ItemsViewModel>(
supplier:{letstate=ItemsViewModel.State()
state.itemsFetchingState =.success([.placeholder])return state
}).eraseToAnyReactor()Add a stub reducer only for small interactive preview transitions. Keep fetch, run, subscriptions, and business workflows out of preview stubs.
The package also exposes LegacyReactor for projects using the older Combine-based implementation. New code should use the Reactor product.
Reactor is available under the MIT License. See LICENSE.md.