Puredux 2.0 Release. The Massive One.
Related: Puredux
Recently I released a massive update for the Puredux.
The most recent cool features:
- Single/Multiple Store tree architecture
- UIKit/SwiftUI API improvements (known as Puredux Sugar)
- Dependency Injection
- Performance tuning features
- State-driven side effects
- An Insane Documentation update
Single & Multiple Store Tree Architecture
There were attempts to build multi-store configurations in Puredux but in older versions it was troublesome:
-
There were caveats related to async actions results dispatching to the right store. It was actually solved but with ugly workarounds inside which I hated.
-
There was also a thread explosion issue that could occur in deeply nested trees hierarchy because each child store was operating on its own queue. That's why in older versions the hierarchy depth was limited by 2 and supported only the "root parent" + "child" configuration.
I've rewritten the internals to allow the whole store hierarchy to operate on a single queue which allowed me to improve the performance dramatically.
Now a single action dispatch is always a single hop to the internal queue and a single update of each observer, no matter the tree depth.
It also means that multistore store decomposition is now a zero cost which is kinda of cool because it solves all questions related to scope and feature isolation, as well as state lifecycle.
UIKit & SwiftUI integration API improvements
UIKit API was slightly tuned to be as close to UIViewController and UIView as possible:
final class MyViewController: ViewController {
let store = StoreOf[\.root]
override func viewDidLoad() {
super.viewDidLoad()
subscribe(store) { [weak self] in
// Handle updates with the derived props
self?.updateUI(with: $0)
}
}
private func updateUI(with state: AppState) {
// Update UI elements with the new view state
}
}
SwiftUI API was massively refined for simplicity and more of a SwiftUI vibes:
struct ContentView: View {
@State var store = StoreOf[\.root]
@State var viewState: (AppState)?
var body: some View {
MyView(viewState)
.subscribe(store) { viewState = $0 }
.onAppear {
dispatch(SomeAction())
}
}
}
Dependency Injection
In practice, it turns out that it's hard to make the code 100% free from dependencies even if it is a pure reducer. There definitely will be something that shows up. A certain uuid or exact moment of time, environment config, or maybe a feature flag.
Since the Puredux stores operate in the background there are certain caveats related to using Dependency Injection in a multithreaded environment.
So I made it.
Performance Tuning
I added advanced performance tuning capabilities. The idea is the following:
- You are casually building an iOS app with Puredux
- One of the screens in your app turns out to be insanely complicated and performs poorly. Every app should have a complicated screen, isn't it?
- You spot the reason for the poor performance and apply one of the built-in Puredux performance tuning solutions.
- It starts to work well.
Puredux now supports the following toolset:
- Granular UI updates: any View or UIViewController or UIView can be individually subscribed to the store or a part of it.
- UI updates with deduplication and debouncing: a silver bullet against inevitably frequent store updates
- UI updates breakdown: UI updates can be split into 2 steps, allowing to offload heavy computations to a separate background queue.
State Driven Side Effects
I've been prototyping the feature for a very long time and could never get it done without a ton of boilerplate code. Finally, I did.
Here is a more detailed explanation of the idea: State-Driven Side Effects
There is actually a challenge in side effects implementation for UDF architectures. You need to put side effects somewhere and each place has its trade-offs.
- Intercepting middleware makes life with a multistore configuration harder: actions async should be executed once and the result delivered to the right store.
- Async actions bring dependencies to actions which makes it hard to keep actions pure plain structs
- Feedback loops require execution middleware or bring dependencies into reducers.
State-driven side effects do not have these downsides.
- They don't require middleware, allowing for complicated multistore hierarchies
- Actions can remain plain structs without dependencies.
- Reducers can remain pure functions without dependencies.
State-driven side effects make complicated sequences of different kinds of work simple. As well as task cancellation, delaying, and waiting or retries.
Here is an example:
// Add effect state to the state
struct AppState {
private(set) var theJob: Effect.State = .idle()
}
// Add related actions**
enum Action {
case jobSuccess(Something)
case startJob
case cancelJob
case jobFailure(Error)
}
// Handle actions in the reducer
extension AppState {
mutating func reduce(_ action: Action) {
switch action {
case .jobSuccess:
theJob.succeed()
case .startJob:
theJob.run()
case .cancelJob:
theJob.cancel()
case .jobFailure(let error):
theJob.retryOrFailWith(error)
}
}
}
// Add SideEffect to the store:
let store = StateStore<AppState, Action>(AppState()) { state, action in
state.reduce(action)
}
.effect(\.theJob, on: .main) { appState, dispatch in
// effect will be created and scheduled for execution
// once `theJob` state turns into "running" state
Effect {
do {
let result = try await apiService.fetch()
dispatch(.jobSuccess(result))
} catch {
dispatch(.jobFailure(error))
}
}
}
// Dispatch action to run the job:
store.dispatch(.startJob)
Documentation Update
That's probably the best documentation I ever wrote in my life.
Comments