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
func transformAction() -> AnyPublisher<Action, Never> {
// Globally observe all ChildReactable actions and changed states
let childEvent = ChildReactable.observe()
.filter { result in // result contains the action that occurred and the Child State at the end of the action
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
// Observe specific reactable actions
let localChildEvent = self.currentState.childReactable.observe()
.filter { result in // result contains the action that occurred and the Child State at the end of the action
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
return .merge([
childEvent,
])
} 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.