Skip to content

Topwiz/ReactableKit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

94 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸš€ ReactableKit

πŸ‡°πŸ‡· ν•œκ΅­μ–΄ ReadMe

πŸ“Œ Introduction

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.

πŸ“‹ Requirements

  • βœ… iOS 16.0+
  • βœ… Swift 5+

πŸ“¦ Installation

Swift Package Manager (SPM)

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")
]

πŸ“š Table of Contents

⚑ Basic Usage

🎨 Property Wrappers

πŸ”§ Features


⚑ Basic Usage

1️⃣ Core Structure of Reactable

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
        }
    }
}

2️⃣ Action Transformation with transformAction

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 using Store and creating directly, make sure to call initialize() in the init method.

3️⃣ SwiftUI and Reactable

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)
            }
        }
    }
}

4️⃣ updateOn: SwiftUI Update Optimization

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")
                        }
                    }
                }
            }
        }
    }
}

5️⃣ Action Dispatch

Normal Action

store.action(.increase)

Concurrency Action

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)")

🎨 Property Wrappers

@ViewState

@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

@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 = ""
}

⚠️ @Shared does not automatically update the UI when values change.

@SharedViewState

@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: Setting ignoreEquality to true may cause unnecessary updates to the SwiftUI view.

@Emit State Tracking

@Emit triggers updates even when the same value is set.

struct State {
    @Emit var title: String = "Hello"
}

Subscribing to emit(_:)

reactable.emit(\.$title)
    .sink { newValue in
        print("Title changed:", newValue)
    }
    .store(in: &cancellables)

Using @Emit in SwiftUI

ZStack { }
.emit(\.$title, from: self.store) { value in
    print("Title updated:", value)
}

πŸ”§ Features

ObservableEvent (Parent-Child Communication)

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,
    ])
}  

ReactableView Protocol

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) { 

    }
}

DependencyInjectable & Factory Pattern

Combines dependency injection system with factory pattern to simplify object creation and dependency management in real, preview, and test environments.

1. DependencyInjectable

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 service

2. Factory

Use ViewFactory for 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 testObjectFactory

3. AnyFactory

AnyFactory 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())
    }
}

πŸ—οΈ Roadmap

  • πŸ’» Mac Support
  • πŸš€ Performance Optimizations

πŸ”— References

πŸ“œ License

ReactableKit is available under the MIT license.