ReactableKit is a lightweight yet powerful state management framework for SwiftUI applications built on Combine.
Inspired by ReactorKit architecture, this framework provides a structured approach to efficiently handle business logic and state transformations.
- ✅ iOS 16.0+
- ✅ Swift 5+
You can easily install ReactableKit using Swift Package Manager. Open your project in Xcode, select File > Add Packages... from the menu, and enter the following URL:
https://github.com/topwiz/ReactableKit.git
Or add it directly to your Package.swift file:
dependencies: [
.package(url: "https://github.com/topwiz/ReactableKit.git", from: "version")
]- 1️⃣ Core Structure of Reactable
- 2️⃣ Action Transformation with
transformAction - 3️⃣ SwiftUI and Reactable
- 4️⃣
updateOn: SwiftUI Update Optimization - 5️⃣ Action Dispatch
ObservableEvent(Parent-Child Communication)ReactableViewProtocolDependencyInjectable&FactoryPattern
To use Reactable, create a class that conforms to the Reactable protocol. Define Action, Mutation, and State, then implement mutate(action:) and reduce(state:mutation:).
final class CounterReactable: Reactable {
enum Action: Sendable {
case increase
case decrease
case loadData
}
struct State: Sendable {
var count: Int = 0
var isLoading: Bool = false
var data: String = ""
var errorMessage: String? = nil
}
enum Mutation: Sendable {
case setCount(Int)
case setLoading(Bool)
case setData(String)
case setError(String)
}
let initialState = State()
func mutate(action: Action) -> AnyPublisher<Mutation, Never> {
switch action {
case .increase:
return .just(.setCount(currentState.count + 1))
case .decrease:
return .run { send in
send(.setCount(self.currentState.count - 1))
}
case .loadData:
return .run(priority: .userInitiated) { send in
send(.setLoading(true))
// Simulate async operation that can throw
try await Task.sleep(nanoseconds: 1_000_000_000)
let data = try await fetchDataFromAPI()
send(.setData(data))
send(.setLoading(false))
} catch: { error, send in
// Handle errors safely
send(.setLoading(false))
send(.setError(error.localizedDescription))
}
}
}
func reduce(state: inout State, mutation: Mutation) {
switch mutate {
case let .setCount(value):
state.count = value
case let .setLoading(isLoading):
state.isLoading = isLoading
case let .setData(data):
state.data = data
case let .setError(error):
state.errorMessage = error
}
}
}transformAction automatically enables event-based action triggers. This is useful for converting timers and various events into Reactable Actions.
func transformAction() -> AnyPublisher<Action, Never> {
return Timer.publish(every: 5, on: .main, in: .common)
.autoconnect()
.map { _ in Action.autoIncrease }
.eraseToAnyPublisher()
}
⚠️ Important: When not usingStoreand creating directly, make sure to callinitialize()in theinitmethod.
Use Store to detect state changes and dispatch Action in SwiftUI Views.
struct CounterView: View {
@StateObject var store = Store {
CounterReactable()
}
var body: some View {
VStack(spacing: 20) {
Text("\(self.store.state.count)")
.font(.largeTitle)
Button("Increase") {
self.store.action(.increase)
}
}
}
}You can reduce SwiftUI updates by monitoring only specific states:
struct OptimizedView: View {
@ObservedObject var store = Store { CounterReactable() }
var body: some View {
VStack {
// ✅ Updates only when `count` changes
self.store.updateOn(\.count) { value in
Text("\(value)")
.font(.headline)
}
// ✅ Using with action
self.store.updateOn(\.isOn) { value in
Toggle(isOn: value) {
Text("Toggle")
}
} action: { newValue in
.toggleChanged
}
// ✅ ForEach example
ForEach(self.store.state.items) { item in
self.store.updateOn(\.items, for: item.id) { value in
Text("\(value.name)")
}
}
// ✅ ForEach List multiple views example
ForEach(self.store.state.list) { item in
HStack {
self.store.updateOn(\.list, for: item.id, property: \.index) { value in
Text("\(value)")
.font(.headline)
}
self.store.updateOn(\.list, for: item.id, property: \.toggle) { value in
Toggle(isOn: value) {
Text("Toggle 2 updateOn")
}
}
}
}
}
}
}store.action(.increase)You can receive the final state after the action is completely processed and the state is updated.
let finalState = await store.asyncAction(.increase)
print("Final count: \(finalState.count)")@ViewState ensures automatic UI updates when values change. Properties without @ViewState do not trigger SwiftUI updates.
struct State {
@ViewState var count: Int = 1
/// When ignoreEquality = true, SwiftUI View updates even when the same value is set
@ViewState(ignoreEquality: true) var forceUpdate: Bool = false
/// animation: When animation is set, animation is applied when the value changes
@ViewState(animation: .default) var animatedValue: Double = 0.0
}@Shared enables state sharing between parent and child components.
/// `file` and `UserDefaults` storage must conform to Codable
struct SharedState: Codable, Equatable {
var username: String = ""
var age: Int = 0
var isPremium: Bool = false
}
struct State {
@Shared(.file()) var sharedState = SharedState()
@Shared(.file(path: "Test/")) var sharedState = SharedState() // Subfolder path
@Shared var sharedState = SharedState()
@Shared(key: "custom_key") var sharedState = SharedState() // Custom key
@ViewState var displayInfo: String = ""
}
⚠️ @Shareddoes not automatically update the UI when values change.
@SharedViewState combines the sharing capabilities of @Shared with the automatic UI updating of @ViewState. It manages shared state values that trigger SwiftUI updates when changed.
struct State {
@SharedViewState var sharedCount: Int = 0
/// When ignoreEquality = true, SwiftUI View updates even when the same value is set
@SharedViewState(ignoreEquality: true) var forceSharedUpdate: Bool = false
/// animation: When animation is set, animation is applied when the value changes
@SharedViewState(animation: .default) var animatedSharedValue: Double = 0.0
}
⚠️ Warning: SettingignoreEqualitytotruemay cause unnecessary updates to the SwiftUI view.
@Emit triggers updates even when the same value is set.
struct State {
@Emit var title: String = "Hello"
}reactable.emit(\.$title)
.sink { newValue in
print("Title changed:", newValue)
}
.store(in: &cancellables)ZStack { }
.emit(\.$title, from: self.store) { value in
print("Title updated:", value)
}ObservableEvent enables action transmission between child and parent components.
// Child Reactable
class ChildReactable: Reactable, ObservableEvent {
enum Action {
case notifyParent(Int)
}
}
// Parent Reactable - observe all instances (global)
func transformAction() -> AnyPublisher<Action, Never> {
let childEvent = ChildReactable.observe()
.filter { result in
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
// Observe specific instance (when child is always in state)
let localChildEvent = self.currentState.childReactable.observe()
.filter { result in
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
return .merge([childEvent, localChildEvent])
}When the same child type is used by multiple parents, ChildType.observe() delivers events to all parents. Use child to receive only events from your child instance. Always call .observe() to subscribe:
// Parent State - child can be non-optional or optional
struct State {
var childReactable: ChildReactable // non-optional
var optionalChild: ChildReactable? // optional (e.g. lazy-loaded)
}
// Parent transformAction
func transformAction() -> AnyPublisher<Action, Never> {
// Non-optional: call .observe() to subscribe
let childEvents = self.child(\.childReactable)
.observe()
.filter { result in
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
// Optional: same pattern – call .observe() to subscribe
let optionalChildEvents = self.child(\.optionalChild)
.observe()
.filter { result in
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
return .merge([childEvents, optionalChildEvents])
}child benefits:
- Unified API: Both optional and non-optional require
.observe()– no confusion about when to call it - Filters by
sourceId: Only your child's events are delivered - No wrong-parent routing: When multiple parents share the same child type, each parent receives only its own child's events
When you have nested optional children (e.g. parent → optionalChild? → optionalGrandchild?), chain child calls and call .observe() at the end:
// 2-level chain (optional → optional)
self.child(\.fullRouteReactable)
.child(\.routeDetailReactable)
.observe()
.sink { result in ... }
.store(in: &cancellables)
// optional → non-optional grandchild
self.child(\.optionalChild)
.child(\.grandchild)
.observe()
.sink { ... }
.store(in: &cancellables)
// Single-level optional
self.child(\.optionalChild).observe().sink { ... }public struct ObservableEventResult<R: Reactable> {
public let action: R.Action
public var state: R.State
/// Identifies the Reactable instance that sent this event (for `child` filtering)
public let sourceId: ObjectIdentifier
}Use the ReactableView protocol that follows @MainActor in UIKit views.
final class UIKitView: UIView {
var cancellables: Set<AnyCancellable> = []
override init(frame: CGRect) {
super.init(frame: frame)
self.reactable = .init()
}
}
extension UIKitView: ReactableView {
// Called when self.reactable is set
func bind(reactable: UIKitReactable) {
}
}Combines dependency injection system with factory pattern to simplify object creation and dependency management in real, preview, and test environments.
protocol ServiceProtocol {
func test() -> String
}
struct Service: ServiceProtocol {
func test() -> String { "real" }
struct Mock: ServiceProtocol {
public init() {}
public func test() -> String { "mock" }
}
struct TestMock: ServiceProtocol {
public init() {}
public func test() -> String { "test" }
}
}
// Use `MainActorDependencyInjectable` if you need to follow MainActor
extension Service: DependencyInjectable {
static var real: ServiceProtocol { Service() }
static var preview: ServiceProtocol { Service.Mock() }
static var test: ServiceProtocol { Service.TestMock() }
}
extension GlobalDependencyKey {
var service: ServiceProtocol {
self[Service.self]
}
}
// usage
@Dependency(\.service) var serviceUse
ViewFactoryfor factories that require @MainActor.
final class TestObject: Factory {
struct Payload {
var text: String
}
let payload: Payload
init(payload: Payload) {
self.payload = payload
}
func print1() {
print(self.payload.text)
}
}
extension TestObject: DependencyInjectable {
typealias DependencyType = TestObject.Factory
static var real: TestObject.Factory { .init() }
}
extension GlobalDependencyKey {
var testObjectFactory: TestObject.Factory {
self[TestObject.self]
}
}
// usage
@Dependency(\.testObjectFactory) var testObjectFactoryAnyFactory is a generic wrapper that abstracts the object creation process.
It creates objects using Factory and transforms them into the desired output type through transformation closures.
extension MyFactory: DependencyInjectable {
typealias DependencyType = AnyFactory<`ProtocolType`, Payload>
static var real: DependencyType {
AnyFactory(factory: MyFactory.Factory())
}
static var test: DependencyType {
AnyFactory(factory: MockFactory.Factory())
}
}- 💻 Mac Support
- 🚀 Performance Optimizations
ReactableKit is available under the MIT license.