Unidirectional Dataflow for your favourite reactive framework
If you've got questions, about SwiftRex or redux and Functional Programming in general, please Join our Slack Channel.
This is part of "SwiftRex library". Please read the library documentation to have full context about what Reducer is used for.
SwiftRex is a framework that combines Unidirectional Dataflow architecture and reactive programming (Combine, RxSwift or ReactiveSwift), providing a central state Store for the whole state of your app, of which your SwiftUI Views or UIViewControllers can observe and react to, as well as dispatching events coming from the user interactions.
This pattern, also known as "Redux", allows us to rethink our app as a single pure function that receives user events as input and returns UI changes in response. The benefits of this workflow will hopefully become clear soon.
API documentation can be found here.
Reducer
is a pure function wrapped in a monoid container, that takes an action and the current state to calculate the new state.
The MiddlewareProtocol
pipeline can do two things: dispatch outgoing actions and handling incoming actions. But what they can NOT do is changing the app state. Middlewares have read-only access to the up-to-date state of our apps, but when mutations are required we use the MutableReduceFunction
function:
(ActionType, inout StateType) -> Void
Which has the same semantics (but better performance) than old ReduceFunction
:
(ActionType, StateType) -> StateType
Given an action and the current state (as a mutable inout), it calculates the new state and changes it:
initial state is 42
action: increment
reducer: increment 42 => new state 43
current state is 43
action: decrement
reducer: decrement 43 => new state 42
current state is 42
action: half
reducer: half 42 => new state 21
The function is reducing all the actions in a cached state, and that happens incrementally for each new incoming action.
It's important to understand that reducer is a synchronous operations that calculates a new state without any kind of side-effect (including non-obvious ones as creating Date()
, using DispatchQueue or Locale.current
), so never add properties to the Reducer
structs or call any external function. If you are tempted to do that, please create a middleware and dispatch actions with Dates or Locales from it.
Reducers are also responsible for keeping the consistency of a state, so it's always good to do a final sanity check before changing the state, like for example check other dependant properties that must be changed together.
Once the reducer function executes, the store will update its single source-of-truth with the new calculated state, and propagate it to all its subscribers, that will react to the new state and update Views, for example.
This function is wrapped in a struct to overcome some Swift limitations, for example, allowing us to compose multiple reducers into one (monoid operation, where two or more reducers become a single one) or lifting reducers from local types to global types.
The ability to lift reducers allow us to write fine-grained "sub-reducer" that will handle only a subset of the state and/or action, place it in different frameworks and modules, and later plugged into a bigger state and action handler by providing a way to map state and actions between the global and local ones. For more information about that, please check Lifting.
A possible implementation of a reducer would be:
let volumeReducer = Reducer<VolumeAction, VolumeState>.reduce { action, currentState in
switch action {
case .louder:
currentState = VolumeState(
isMute: false, // When increasing the volume, always unmute it.
volume: min(100, currentState.volume + 5)
)
case .quieter:
currentState = VolumeState(
isMute: currentState.isMute,
volume: max(0, currentState.volume - 5)
)
case .toggleMute:
currentState = VolumeState(
isMute: !currentState.isMute,
volume: currentState.volume
)
}
}
Please notice from the example above the following good practices:
- No
DispatchQueue
, threading, operation queue, promises, reactive code in there. - All you need to implement this function is provided by the arguments
action
andcurrentState
, don't use any other variable coming from global scope, not even for reading purposes. If you need something else, it should either be in the state or come in the action payload. - Do not start side-effects, requests, I/O, database calls.
- Avoid
default
when writingswitch
/case
statements. That way the compiler will help you more. - Make the action and the state generic parameters as much specialised as you can. If volume state is part of a bigger state, you should not be tempted to pass the whole big state into this reducer. Make it short, brief and specialised, this also helps preventing
default
case or having to re-assign properties that are never mutated by this reducer.
┌────────┐
IO closure ┌─▶│ View 1 │
┌─────┐ (don't run yet) ┌─────┐ │ └────────┘
│ │ handle ┌──────────┐ ┌───────────────────────────────────────▶│ │ send │ ┌────────┐
│ ├────────▶│Middleware│──┘ │ │────────────▶├─▶│ View 2 │
│ │ Action │ Pipeline │──┐ ┌─────┐ reduce ┌──────────┐ │ │ New state │ └────────┘
│ │ └──────────┘ └─▶│ │───────▶│ Reducer │──────────▶│ │ │ ┌────────┐
┌──────┐ dispatch │ │ │Store│ Action │ Pipeline │ New state │ │ └─▶│ View 3 │
│Button│─────────▶│Store│ │ │ + └──────────┘ │Store│ └────────┘
└──────┘ Action │ │ └─────┘ State │ │ dispatch ┌─────┐
│ │ │ │ ┌─────────────────────────┐ New Action │ │
│ │ │ │─run──▶│ IO closure ├────────────▶│Store│─ ─ ▶ ...
│ │ │ │ │ │ │ │
│ │ │ │ └─┬───────────────────────┘ └─────┘
└─────┘ └─────┘ │ ▲
request│ side-effects │side-effects
▼ response
┌ ─ ─ ─ ─ ─ │
External │─ ─ async ─ ─ ─
│ World
─ ─ ─ ─ ─ ┘
An app can be a complex product, performing several activities that not necessarily are related. For example, the same app may need to perform a request to a weather API, check the current user location using CLLocation and read preferences from UserDefaults.
Although these activities are combined to create the full experience, they can be isolated from each other in order to avoid URLSession logic and CLLocation logic in the same place, competing for the same resources and potentially causing race conditions. Also, testing these parts in isolation is often easier and leads to more significant tests.
Ideally we should organise our AppState
and AppAction
to account for these parts as isolated trees. In the example above, we could have 3 different properties in our AppState and 3 different enum cases in our AppAction to group state and actions related to the weather API, to the user location and to the UserDefaults access.
This gets even more helpful in case we split our app in 3 types of Reducer
and 3 types of MiddlewareProtocol
, and each of them work not on the full AppState
and AppAction
, but in the 3 paths we grouped in our model. The first pair of Reducer
and MiddlewareProtocol
would be generic over WeatherState
and WeatherAction
, the second pair over LocationState
and LocationAction
and the third pair over RepositoryState
and RepositoryAction
. They could even be in different frameworks, so the compiler will forbid us from coupling Weather API code with CLLocation code, which is great as this enforces better practices and unlocks code reusability. Maybe our CLLocation middleware/reducer can be useful in a completely different app that checks for public transport routes.
But at some point we want to put these 3 different types of entities together, and the StoreType
of our app "speaks" AppAction
and AppState
, not the subsets used by the specialised handlers.
enum AppAction {
case weather(WeatherAction)
case location(LocationAction)
case repository(RepositoryAction)
}
struct AppState {
let weather: WeatherState
let location: LocationState
let repository: RepositoryState
}
Given a reducer that is generic over WeatherAction
and WeatherState
, we can "lift" it to the global types AppAction
and AppState
by telling this reducer how to find in the global tree the properties that it needs. That would be \AppAction.weather
and \AppState.weather
. The same can be done for the middleware, and for the other 2 reducers and middlewares of our app.
When all of them are lifted to a common type, they can be combined together using Reducer.compose(reducer1, reducer2, reducer3...)
function, or the DSL form:
Reducer.compose {
reducer1
reducer2
Reducer
.login
.lift(action: \.loginAction, state: \.loginState)
Reducer
.lifecycle
.lift(action: \.lifecycleAction, state: \.lifecycleState)
Reducer.app
Reducer.reduce { action, state in
// some inline reducer
}
}
IMPORTANT: Because enums in Swift don't have KeyPath as structs do, we strongly recommend reading Action Enum Properties document and implementing properties for each case, either manually or using code generators, so later you avoid writing lots and lots of error-prone switch/case. We also offer some templates to help you on that.
Let's explore how to lift reducers and middlewares.
Reducer
has AppAction INPUT, AppState INPUT and AppState OUTPUT, because it can only handle actions (never dispatch them), read the state and write the state.
The lifting direction, therefore, should be:
Reducer:
- ReducerAction? ← AppAction
- ReducerState ←→ AppState
Given:
// type 1 type 2
Reducer<ReducerAction, ReducerState>
Transformations:
╔═══════════════════╗
║ ║
╔═══════════════╗ ║ ║
║ Reducer ║ .lift ║ Store ║
╚═══════════════╝ ║ ║
│ ║ ║
╚═══════════════════╝
│ │
│ │
┌───────────┐
┌─────┴─────┐ (AppAction) -> ReducerAction? │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ Reducer │ { $0.case?.reducerAction } │ │
Input Action │ Action │◀──────────────────────────────────────────────│ AppAction │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ KeyPath<AppAction, ReducerAction?> │ │
└─────┬─────┘ \AppAction.case?.reducerAction │ │
└───────────┘
│ │
│ get: (AppState) -> ReducerState │
{ $0.reducerState } ┌───────────┐
┌─────┴─────┐ set: (inout AppState, ReducerState) -> Void │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ Reducer │ { $0.reducerState = $1 } │ │
State │ State │◀─────────────────────────────────────────────▶│ AppState │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ WritableKeyPath<AppState, ReducerState> │ │
└─────┬─────┘ \AppState.reducerState │ │
└───────────┘
│ │
.lift(
actionGetter: { (action: AppAction) -> ReducerAction? /* type 1 */ in
// prism3 has associated value of ReducerAction,
// and whole thing is Optional because Prism is always optional
action.prism1?.prism2?.prism3
},
stateGetter: { (state: AppState) -> ReducerState /* type 2 */ in
// property2: ReducerState
state.property1.property2
},
stateSetter: { (state: inout AppState, newValue: ReducerState /* type 2 */) -> Void in
// property2: ReducerState
state.property1.property2 = newValue
}
)
Steps:
- Start plugging the 2 types from the Reducer into the 3 closure headers.
- For type 1, find a prism that resolves from AppAction into the matching type. BE SURE TO RUN SOURCERY AND HAVING ALL ENUM CASES COVERED BY PRISM
- For type 2 on the stateGetter closure, find lenses (property getters) that resolve from AppState into the matching type.
- For type 2 on the stateSetter closure, find lenses (property setters) that can change the global state receive to the newValue received. Be sure that everything is writeable.
.lift(
action: \AppAction.prism1?.prism2?.prism3,
state: \AppState.property1.property2
)
Steps:
- Start with the closure example above
- For action, we can use KeyPath from
\AppAction
traversing the prism tree - For state, we can use WritableKeyPath from
\AppState
traversing the properties as long as all of them are declared asvar
, notlet
.
Identity:
- when some parts of your lift should be unchanged because they are already in the expected type
- lift that using
identity
, which is{ $0 }
// Reducer closure
.lift(
actionGetter: { (action: AppAction) -> <#LocalAction#>? in action.<#something?.child#> },
stateGetter: { (state: AppState) -> <#LocalState#> in state.<#something.child#> },
stateSetter: { (state: inout AppState, newValue: <#LocalState#>) -> Void in state.<#something.child#> = newValue }
)
// Reducer KeyPath:
.lift(
action: \AppAction.<#something?.child#>,
state: \AppState.<#something.child#>
)
Please use SwiftRex installation instructions, instead. Reducers are not supposed to be used independently from SwiftRex.