Mobindustry merges with Apriorit,
a Specialized Cybersecurity R&D Company
Month: July 2022
Redux meets Swift: Asynchronicity
Hi, Dmytro here. As I told you in our previous article, Redux is an open-source library developed in JavaScript. It mainly specializes in building webpages in single-threaded environments. However, on iOS and macOS we usually work in a multi-threaded environment. Therefore, iOS apps have much more complicated logic than webpages have. To be able to develop iOS applications using Redux, we need to overcome a single-threaded environment limitation. This becomes possible with the use of middleware.
Middleware
Middleware is code you can put between the framework receiving a request and the framework generating a response. It’s widely used in such server-side libraries as Express and Koa. However, unlike Express and Koa middleware, Redux middleware serves a slightly different purpose. More specifically, it provides a third-party extension point between a dispatcher that is dispatching an action and a reducer that this action is reaching. Usually, Redux middleware is used for logging, crash reporting, talking to an asynchronous API, routing, etc.
In fact, there can be many middlewares within one application. The function of middleware is similar to that of a reducer, but a reducer only changes the application’s state in accordance with the received action, while middleware carries out all the business logic.
For instance, the way a middleware protocol is implemented with just one method:
protocol Middleware: class { func handle(action: Action) -> Action }
is similar to how the reducer protocol is implemented:
protocol Reducer { func reduce(action: Action, state: State) -> State }
However, middleware’s role is to perform logic and pass the action further:
let action = self.middlewares.reduce(action, { (action, middleware) -> Action in return middleware.handle(action: action) })
On the one hand, the majority of experts advise against altering an action drastically during the coding. This way the code is more stable: middleware handles the events while reducers process them to the store. On the other hand, it would be useful to add more information to the original action during processing. For instance, ValidationMiddleware could validate a phone number in ChangePhoneAction immediately and update it with validation results:
struct ChangePhoneAction: Action { var phone: String var isValid: Bool? }
class ValidationMiddleware: Middleware { let phoneValidator = PhoneValidator() func handle(action: Action) -> Action? { if var action = action as? ChangePhoneAction { action.isValid = phoneValidator.validate(phone: action.phone) } return action } }
Persistence middleware can unload saved data from the local database, attach it to InitStoreAction, and reducers will use it to restore the last saved state.
Middleware can also perform any asynchronous work: execute network requests or perform heavy media processing tasks. These tasks will look the same as usual – the same logic, callbacks, promises, etc. There’s just one difference: at the end of every task, your code will return a new action that informs us about the result:
struct LoginAction: Action { var login: String var password: String } struct LoginSuccessAction: Action { var apiToken: String } struct LoginFailureAction: Action { var error: Error }
class SessionMiddleware: Middleware { let network = NetworkService() let store: Store! func handle(action: Action) -> Action? { if let action = action as? LoginAction { network.loginRequest(login: action.login, password: action.password) { (apiToken, error) in if let apiToken = apiToken { let successAction = LoginSuccessAction(apiToken: apiToken) self.store.reduce(action: successAction) } else if let error = error { let failureAction = LoginFailureAction(error: error) self.store.reduce(action: failureAction) } } } } }
Of course, middleware can refer to other services. We just have to follow the principle that services don’t store more data than required for their current task. For example, all session data has to be stored in SessionState for NetworkingService to get the data from there and set it to every single request. NetworkingService can also subscribe itself to updates of SessionState and immediately update its cached session data each time it’s updated in the store. This function follows one of the basic ideas of Redux: the state is the single source of truth.
So what is the benefit of using middleware? The answer is simple. It gives us a single entry point every bit of logic. Anything your app does you can track with the help of corresponding middleware. To start any work, you need only one method and the list of events this method handles. Thanks to this, code becomes extremely easy to read, change, cover with tests, and debug. In fact, middlewares are pure functions that are easy to test. They don’t store any states and don’t change behavior. Therefore, they’re able to produce more or less predictable results.
Reducers
Now that you have general knowledge about what good middleware can do, let’s take a closer look at the features of reducers. Although reducers don’t carry out any business logic, they specify changes to the application’s state in response to actions. It’s their main purpose.
A simple analogy will help us understand how reducers work. Imagine that a reducer is a coffee maker. It takes in coffee beans and water and produces a freshly brewed cup of coffee. In other words, reducers are functions that take in the current state (coffee beans) and actions (water) and brew a new state (fresh coffee).
function(state, action) => newState
As long as we don’t add any coffee supplements, we receive pure coffee. The same goes with reducers: given the same input, they always return the same output. They don’t produce any side effects – no API calls. This makes reducers qualify as pure functions.
struct MainState: State { var counter = 0 } struct DoSomething: Action { } struct MainReducer: Reducer { func reduce(action: Action, state: State) -> State { if var state = state as? MainState, action is DoSomething { state.counter += 1 } } }
As our apps have a lot of data, we need to use nested sub-states to organize all the data in an app’s state. The sheer fact that reducers don’t alter the code makes working with sub-states the same as working with root states. Thanks to this it’s much easier to conduct standard tests, as reducers don’t do asynchronous work and don’t use data other than the current action and state.
struct MainState: State { var counter = 0 var aSubstate: State var bSubstate: State } struct ASubstate: State { var i = 0 } struct BSubstate: State { var name = "Default substate" } struct DoSomething: Action { } struct MainReducer: Reducer { let aReducer = AReducer() let bReducer = BReducer() func reduce(action: Action, state: State) -> State { if var state = state as? MainState, action is DoSomething { state.counter += 1 state.aSubstate = aReducer.reduce(action: action, state: state.aSubstate) state.bSubstate = bReducer.reduce(action: action, state: state.bSubstate) } } }
struct AReducer: Reducer { func reduce(action: Action, state: State) -> State { if var state = state as? ASubstate, action is DoSomething { state.i += 1 } return state } } struct BReducer: Reducer { func reduce(action: Action, state: State) -> State { if var state = state as? BSubstate, action is DoSomething { state.name = "Changed substate" } return state } }
Finally, we receive the state tree where we store all our data. As you can see, the reducers reflect the state tree. This structure is preferable in order to ensure that your code is organized properly. In this case, every action initiates a cascade of updates to the state. The same happens at every level.
Following the described principles, we can develop a full-fledged application. Depending on your actions while using Redux, you might encounter certain performance issues. Our next article will address possible complications while working with Redux.