- Notifications
You must be signed in to change notification settings - Fork0
SwiftRex/Reducer
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Unidirectional Dataflow for your favourite reactive framework
If you've got questions, about SwiftRex or redux and Functional Programming in general, pleaseJoin 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 orReactiveSwift), 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 singlepure 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.
TheMiddlewareProtocol 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 theMutableReduceFunction function:
(ActionType, inout StateType)-> Void
Which has the same semantics (but better performance) than oldReduceFunction:
(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 42action: incrementreducer: increment 42 => new state 43current state is 43action: decrementreducer: decrement 43 => new state 42current state is 42action: halfreducer: half 42 => new state 21The 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 creatingDate(), using DispatchQueue orLocale.current), so never add properties to theReducer 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 checkLifting.
A possible implementation of a reducer would be:
letvolumeReducer=Reducer<VolumeAction,VolumeState>.reduce{ action, currentStateinswitch 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
actionandcurrentState, 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
defaultwhen writingswitch/casestatements. 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
defaultcase 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 ourAppState andAppAction 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 ofReducer and 3 types ofMiddlewareProtocol, and each of them work not on the fullAppState andAppAction, but in the 3 paths we grouped in our model. The first pair ofReducer andMiddlewareProtocol would be generic overWeatherState andWeatherAction, the second pair overLocationState andLocationAction and the third pair overRepositoryState andRepositoryAction. 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 theStoreType of our app "speaks"AppAction andAppState, not the subsets used by the specialised handlers.
enumAppAction{case weather(WeatherAction)case location(LocationAction)case repository(RepositoryAction)}structAppState{letweather:WeatherStateletlocation:LocationStateletrepository:RepositoryState}
Given a reducer that is generic overWeatherAction andWeatherState, we can "lift" it to the global typesAppAction andAppState 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 usingReducer.compose(reducer1, reducer2, reducer3...) function, or the DSL form:
Reducer.compose{ reducer1 reducer2Reducer.login.lift(action: \.loginAction, state: \.loginState)Reducer.lifecycle.lift(action: \.lifecycleAction, state: \.lifecycleState)Reducer.appReducer.reduce{ action, statein // some inline reducer}}
IMPORTANT: Because enums in Swift don't have KeyPath as structs do, we strongly recommend readingAction 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 ←→ AppStateGiven:
// type 1 type 2Reducer<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:inoutAppState, newValue:ReducerState /* type 2 */)->Voidin // 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
\AppActiontraversing the prism tree - For state, we can use WritableKeyPath from
\AppStatetraversing 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:inoutAppState, newValue:<#LocalState#>)->Voidin 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.
About
SwiftRex Reducer component
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
