Introduction
In this article, you will see an interesting architecture that is very rarely used on iOS. In the first part of this post, you will learn about the concept and implementation and in the second part I will show you mind-blowing features that you can implement in below 60 minutes using Redux architecture.
At first, this architecture may feel unnatural and maybe even complicated, but don’t get discouraged, the concept and implementation are actually very simple, just take your time and look at the code samples, you won’t regret it.
Of course, it is not a Holy Grail, everything has pros and cons, but Redux provides possibilities that no other architecture does. That’s why it is worth at least getting familiar with it.
Demo app
I created a simple application presenting upcoming episodes to show how Redux works. It covers real-life use cases like:
- Handling asynchronous operations.
- Presenting master-detail.
- Presenting work in progress.
- Two types of navigation (push and present/sheet).
- List filtering (search).
- Updating content (posting comments).
You can download the project and follow this article in code.
Common problems in mobile apps
The common problems in mobile apps are usually connected to state management. It is really hard to avoid them using architectures like MVC, MVP, MVVM, VIPER, etc. because they don’t protect applications from falling into an unexpected state like when data is loaded but the spinner is still visible, or button is disabled when it should be enabled, or user is logged out, but still some account-related data is visible.
Other architectures put the main emphasis on separating layers which causes even more problems with keeping the application state consistent. Especially since we use a lot of singletons in our Dependency Injection containers which are modified from different places in an asynchronous manner.
The bigger application becomes, the more complicated and hard to maintain its state becomes. That’s why most applications even if started well end up with a messed up state that requires a lot of refactors.
What usually leads to problems?
- Making implicit assumptions that some functions should be called in a specific order.
- Making implict assumptions that some functions should not be called multiple times.
- Producing side-effects by functions. Which means that a function changes something outside of its scope.
- Relying on side-effects within a function. Which means that a function uses values from outside of its parameters.
- Maintaining multiple local variables which often grow when application grows.
- Asynchronous operations which may lead to unexpected order of calls (race condition).
Slippery slope
The multiplicity of assumptions and dependencies cause that whenever we touch a class we haven’t seen before, we must read the whole code to at least have a basic idea of how everything is connected so that we don’t break its state. You can’t call a function before reading it, because you know it may change something outside of its scope or maybe it is not meant to be called at this moment. Maybe there is some guard
inside. It’s a highway to bugs, you just forget to switch some variable or notify another object, and here we are.
Of course, there is many more examples and problems, but in general, the real problem is that we don’t have any control over the synchronization of our application’s state. Everything changes independently in classic architectures, especially since we want to have everything as much decoupled as possible, but on the other hand, everything relies on each other. Here comes the Redux to protect the state.
Redux architecture
Redux architecture is meant to protect changes in an application’s state. It forces you to define clearly what state should be set when a specific action is dispatched.
It defines 5 simple components which communicate in one direction (this is called unidirectional flow) as presented on the diagram below.
Redux is very lightweight, so we can easily implement all components without using any 3rd party libraries. The whole implementation is below 60 lines.
Redux rules
- There is a single global state kept in store.
- State is immutable.
- New state can be set only by dispatching an action to store.
- New state can be calculated only by reducer which is a pure function (always returns the same result for the same inputs).
- Store notifies subscribers by broadcasting new state.
State
An application should have a single global state which contains everything that defines our views and features. Of course, it doesn’t mean that all things must be in a single struct. The state is usually a composition of multiple smaller states.
It’s up to you how much information you keep in the state. You may for example skip a Bool
value indicating that a text field is focused. However, if you skip too much information, it may turn out that you are not able to fully restore views from the state.
It is best to conform each state and substate to Codable
. It will make things easier in the future.
Example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
struct AppState: Codable { let activeScreens: ActiveScreensState // substate let session: UserSessionState // substate } struct UserSessionState: Codable { let token: String let refreshToken: String let username: String let avatar: URL } struct ActiveScreensState: Codable { let screens: [AppScreenState] } enum AppScreenState: Codable { case splashScreen case home(HomeState) case episode(EpisodeDetailsState) case userProfile(UserDetailsState) } struct HomeState: Codable { let upcomingEpisodes: [UpcomingEpisode] let isLoading: Bool let searchText: String } // etc. |
We use struct
because it is required for the state to be immutable. We could also use class
with lets
, however, it leads to some problems (maybe I will write another post on that).
I like implementing a default init
in an extension for each state to define an initial state.
1 2 3 4 5 6 7 |
extension HomeState { init() { upcomingEpisodes = [] isLoading = true searchText = "" } } |
Actions
Action defines just a simple command to let know reducer what happened within the application. It may also contain some data. For example, an action fetchEpisode
may contain its id.
It is dispatched by view or middleware to the store. Then the store passes it to reducers and middlewares to build a new state.
Our framework definition:
1 |
protocol Action {} |
Example:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
enum HomeStateAction: Action { case fetchUpcomingEpisodes case didReceiveUpcomingEpisodes([UpcomingEpisode]) case filterEpisodes(phrase: String) } enum UserSessionStateAction: Action { case signIn(Credentials) case didSignIn(UserSession) case signOut case didSignOut case didReceiveError(SessionError) } |
Reducers
In Redux architecture we have a reducer for each part of the state. It is responsible for returning a new state based on the old state and action. It’s just a simple function that applies received action to transform the state.
Reducer must be a synchronous pure function which means it should not use anything from outside of its scope and should always return the same result for the same state and action. There is one small exception, it may call other reducers if the received state is a composition of states.
This is a really nice place in Redux where you can clearly see what states are allowed and how they change based on actions. They are easy to read and test.
If the reducer is growing too much you can always split the state into substates and split the reducer accordingly.
Our framework definition:
1 |
typealias Reducer<State> = (State, Action) -> State |
Examples:
1 2 3 4 5 6 7 8 |
extension AppState { static let reducer: Reducer<Self> = { state, action in AppState( activeScreens: ActiveScreensState.reducer(state.activeScreens, action), // substate session: UserSessionState.reducer(state.session, action) // substate ) } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
extension HomeState { static let reducer: Reducer<Self> = { state, action in switch action { case HomeStateAction.fetchUpcomingEpisodes: return HomeState( upcomingEpisodes: [], isLoading: true, searchText: state.searchText ) case HomeStateAction.didReceiveUpcomingEpisodes(let episodes): return HomeState( upcomingEpisodes: episodes, isLoading: false, searchText: state.searchText ) case HomeStateAction.filterEpisodes(let phrase): return HomeState( upcomingEpisodes: state.upcomingEpisodes.filter { phrase == "" || $0.show.title.localizedCaseInsensitiveContains(phrase) }, isLoading: state.isLoading, searchText: phrase ) default: return state } } } |
I like keeping reducers in extensions of the corresponding state. Then you can nicely use it like this: HomeState.reducer
.
Middlewares
We said that the reducer must not produce side effects or use dependencies. So where is a place to put logger, API calls, access storage, or log events to Firebase? Middleware is the right place.
Every time an action is dispatched it should go through all middlewares together with a state. Based on that the middleware can (but doesn’t have to) dispatch a new action(s) asynchronously.
The most common example would be an API call. Middleware receives fetchEpisode
action, calls API, awaits a response, and dispatches another action didReceiveEpisode(data)
with results. This way it does not threaten state safety, because still state calculation will be done in the same way as always.
This is also the real beauty of Redux. It is the only architecture that allows you to truly separate business logic (in reducers) from side effects (in middlewares). What’s more, you can even separate different side-effects from each other: logger in one middleware, Firebase events in the second one, and API calls in the third one.
As you have here access to everything that’s going on within the application, you can add logger or Firebase events to all features at once! And they will automatically support features (actions) added in the future.
It is best to separate each responsibility into different middleware. You can then easily enable or disable some features.
Our framework definition:
1 |
typealias Middleware<State> = (State, Action) -> AnyPublisher<Action, Never> |
Examples:
1 2 3 4 5 6 7 8 9 10 |
import Combine extension Middlewares { static let logger: Middleware<AppState> = { state, action in let stateDescription = "\(state)".replacingOccurrences(of: "ReduxDemo.", with: "") print("➡️ \(action)\n✅ \(stateDescription)\n") return Empty().eraseToAnyPublisher() } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import Combine extension Middlewares { // Instead of extension with static function, this Middleware could be also replaced with a class // and its dependency could be then injected using Dependency Injection. private static let tvShowsRepository = TvShowsRepository() static let tvShows: Middleware<AppState> = { state, action in switch action { case HomeStateAction.fetchUpcomingEpisodes: return tvShowsRepository .fetchUpcomingEpisodes() .map { HomeStateAction.didReceiveUpcomingEpisodes($0) } .ignoreError() .eraseToAnyPublisher() case EpisodeDetailsStateAction.fetchEpisodeDetails(let id): return tvShowsRepository .fetchEpisodeDetails(episodeId: id) .map { EpisodeDetailsAction.didReceiveEpisodeDetails($0) } .ignoreError() .eraseToAnyPublisher() case CommentsStateAction.fetchEpisodeComments(let id): return tvShowsRepository .fetchComments(episodeId: id) .map { CommentsStateAction.didReceiveEpisodeComments($0, episodeId: id) } .ignoreError() .eraseToAnyPublisher() default: return Empty().eraseToAnyPublisher() } } } |
Middlewares – alternative approaches
- You can implement middlewares in many different ways. I like the approach presented above, but you can also insert middlewares directly into Redux flow, so that they also process and optionally skip/replace actions before passing them to reducer (see the diagram below).
- You can combine both approaches having two types of middlewares.
- You can decide wether to pass new state to middleware after executing reducer (my approach) or the old state before executing reducer.
- Middlewares are the most flexible part of Redux, you can use your creativity here. The only rule is not to mutate state from here, just dispatch actions as a result.
Store
The store is the heart that connects all parts of Redux architecture. It is responsible for storing the state, receiving actions, passing actions through the middlewares chain, calculating the new state using reducers, and broadcasting an up-to-date state.
It is important that actions are dispatched on a single thread and new states are processed sequentially. There should be only one global thread-safe instance of a store.
State calculation can be done on the main thread, however when performance issues appear you may consider switching to a background thread.
Full Redux implementation using Combine
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
import Foundation import Combine enum Middlewares {} // Namespace for Middlewares protocol Action {} typealias Reducer<State> = (State, Action) -> State typealias Middleware<State> = (State, Action) -> AnyPublisher<Action, Never> final class Store<State>: ObservableObject { @Published private(set) var state: State private var subscriptions: [UUID: AnyCancellable] = [:] private let queue = DispatchQueue(label: "pl.wojciechkulik.ReduxDemo.store", qos: .userInitiated) private let reducer: Reducer<State> private let middlewares: [Middleware<State>] init( initial state: State, reducer: @escaping Reducer<State>, middlewares: [Middleware<State>] ) { self.state = state self.reducer = reducer self.middlewares = middlewares } func dispatch(_ action: Action) { queue.sync { self.dispatch(self.state, action) } } private func dispatch(_ currentState: State, _ action: Action) { let newState = reducer(currentState, action) middlewares.forEach { middleware in let key = UUID() middleware(newState, action) .receive(on: RunLoop.main) .handleEvents(receiveCompletion: { [weak self] _ in self?.subscriptions.removeValue(forKey: key) }) .sink(receiveValue: dispatch) .store(in: &subscriptions, key: key) } state = newState } } extension AnyCancellable { func store(in dictionary: inout [UUID: AnyCancellable], key: UUID) { dictionary[key] = self } } |
Usage:
1 2 3 4 5 |
let store = Store( initial: AppState(), reducer: AppState.reducer, middlewares: [Middlewares.tvShows, Middlewares.logger] ) |
SwiftUI integration
Redux architecture is meant for declarative UI. Otherwise, you would have to diff states manually, apply changes, undo previous view setup, constraints, etc. SwiftUI resolves all those problems because you simply bind to state, and all diffing, comparing, animating is done under the hood. That’s why Redux now has more sense for SwiftUI apps than for UIKit apps.
I like the approach where we declare store in every view by using @EnvironmentObject
. This way our view will be notified every time our @Published
state changes within the store.
First, you need to create a global instance of your store. I like to put this next to the @main
app class.
1 2 3 4 5 |
let store = Store( initial: AppState(), reducer: AppState.reducer, middlewares: [Middlewares.tvShows, Middlewares.logger] ) |
It is also nice to create an extension that will provide you substate for the current view. In my case, it looks like this, but it all depends on how your state is designed.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
enum AppScreen { case splashScreen case home case episode(id: UUID) } extension AppState { func screenState<State>(for screen: AppScreen) -> State? { return activeScreens.screens .compactMap { switch ($0, screen) { case (.home(let state), .home): return state as? State case (.episode(let state), .episode(let id)) where state.episodeId == id: return state as? State default: return nil } } .first } } |
Then a simple view could be implemented like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import SwiftUI struct HomeView: View { @EnvironmentObject var store: Store<AppState> var state: HomeState? { store.state.screenState(for: .home) } var body: some View { ZStack { if let state = state, !state.isLoading { List { ForEach(state.upcomingEpisodes) { episode in Text("Episode: \(episode.show.title)") } }.listStyle(.plain) } else { ProgressView() } } // This is an extension, we don't want to call this every time onAppear .onLoad { store.dispatch(HomeStateAction.fetchUpcomingEpisodes) } } } struct HomeView_Previews: PreviewProvider { static var previews: some View { NavigationView { HomeView().environmentObject(store) } } } |
Navigation
This is the most tricky part. In UIKit it was very hard to recreate the navigation stack based on state. In SwiftUI it is a little bit easier because now navigation is also declarable by NavigationLink
. I will present below my approach to this topic.
First, I add to my screen state information if details view is visible:
1 2 3 4 |
struct HomeState: Codable { // ... let presentedEpisodeId: UUID? } |
Then I define actions to show and dismiss views:
1 2 3 4 |
enum ActiveScreensStateAction: Action { case showScreen(AppScreen) case dismissScreen(AppScreen) } |
We also need to add a new screen state to our array of active screens:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
extension ActiveScreensState { static let reducer: Reducer<Self> = { state, action in var screens = state.screens // Update visible screens if let action = action as? ActiveScreensStateAction { switch action { case .showScreen(.splashScreen), .dismissScreen(.home), .dismissScreen(.splashScreen): screens = [.splashScreen] case .showScreen(.home): screens = [.home(HomeState())] case .showScreen(.episode(let id)): screens += [.episode(EpisodeDetailsState(id: id))] case .dismissScreen(let screen): screens = screens.filter { $0 != screen } } } // Reduce each screen state screens = screens.map { AppScreenState.reducer($0, action) } return ActiveScreensState(screens: screens) } } |
Now we declare NavigationLink
in our view and call actions appropriately:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
struct HomeView: View { @EnvironmentObject var store: Store<AppState> var state: HomeState? { store.state.screenState(for: .home) } var body: some View { ZStack { if let state = state, !state.isLoading { List { ForEach(state.upcomingEpisodes) { episode in ZStack { episodeRow(for: episode) navigation(for: episode) }.listRowSeparator(.hidden) } }.listStyle(.plain) } else { ProgressView() } } .onLoad { store.dispatch(HomeStateAction.fetchUpcomingEpisodes) } } private func episodeRow(for episode: UpcomingEpisode) -> some View { Button( action: { // When episode is clicked, we dispatch showScreen action store.dispatch(ActiveScreensStateAction.showScreen(.episode(id: episode.id))) }, label: { Text("Episode: \(episode.show.title)") } ) } private func navigation(for episode: UpcomingEpisode) -> some View { // We need here a custom binding NavigationLink( isActive: Binding( // here we decide if details screen is visible get: { episode.id == state?.presentedEpisodeId }, set: { isActive in let currentValue = episode.id == state?.presentedEpisodeId guard currentValue != isActive, !isActive else { return } // when visibility changed and screen was dismissed, we need to update state // by dispatching dismissScreen action store.dispatch(ActiveScreensStateAction.dismissScreen(.episode(id: episode.id))) } ), destination: { EpisodeDetailsView(episodeId: episode.id) }, label: {} ).hidden() // note this link doesn't have to be visible to work } } |
And the last part is the reducer to simply set and unset presentedEpisodeId
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
extension HomeState { static let reducer: Reducer<Self> = { state, action in switch action { case ActiveScreensStateAction.showScreen(.episode(let id)): return HomeState( upcomingEpisodes: state.upcomingEpisodes, isLoading: state.isLoading, presentedEpisodeId: id, searchText: state.searchText ) case ActiveScreensStateAction.dismissScreen(.episode(let id)) where id == state.presentedEpisodeId: return HomeState( upcomingEpisodes: state.upcomingEpisodes, isLoading: state.isLoading, presentedEpisodeId: nil, searchText: state.searchText ) // ... previously defined actions } } } |
I know this might be a little bit overwhelming, but just open my demo application and go through files. You should be able to understand it.
In a very similar way, you can handle sheet navigation.
Redux pros and cons
Pros
- Redux is very lightweight, you can use it without any 3rd party libraries. Implementation takes 60 lines of code.
- You can define the whole application, interactions, and states up-front without even touching UI. It makes adding new features very pleasant.
- Side-effects are separated from business logic. You don’t have to mock anything to test business logic. It is calculated by pure function using just a simple state and an action.
- Reducers are pure functions which makes them extremely testable. You will see that a lot of reducers won’t even require tests because they are very clearly defined without dependencies and asynchronous code.
- Scalability – to add a new feature you just add a new package of substates, reducers, actions, middlewares, and views. You can even easily close each feature within a separate framework. You can also split any of your states into smaller substates.
- Clear definition of your application/module in a single place. It makes the state much more predictable and manageable. You can just read reducer and you will instantly see what can happen to view and how its state changes.
- When everything is relying only on the state, you can easily restore an application to a specific state. Which gives amazing possibilities for development, testing, and bug tracking.
- Thanks to middlewares you can easily add features for the whole application like Firebase events, logging, or some other things without even touching specific features.
- Because of the nice separation of responsibilities, you can easily debug all transitions within the application.
Cons
- Redux is meant for declarative UI. It’s good for SwiftUI, but might be problematic for UIKit.
- Navigation sometimes might be tricky.
- Every time you call an action, the whole state is recreated which may lead to performance issues when it gets too big. However, Swift provides built-in optimizations like copy-on-write for structs, therefore you may never experience this problem.
- Too many middlewares may sometimes lead to unexpected behavior when for example two of them will send opposite actions. Some actions may also lead to an infinite loop.
- If you decide to use Redux in your project, there is no way back. This architecture is too specific to revert it to MVVM or something like that.
- Entry threshold – most mobile developers don’t know/use this architecture. Therefore you will have to teach new people in your team how to work with it.
- You must pay attention to your state structure to avoid duplication of data.
- You must be aware that some actions may flood the store and trigger a lot of state changes. For example, keeping text field content in the state. Or the most extreme case: keeping scroll position in the state. You can also minimize the problem by implementing a specific middleware to debounce some actions.
- The state will become a mix of UI information and domain data which may not look good. However, if it’s getting bigger, you may simply decompose a state into domain and UI.
- Applying Redux will require turning your programming experience upside-down, but once you get used to it, you’re going to love it.
Mind-blowing possibilities
Crash recording and debugging
Your state should be Codable
, so that you could easily save and restore it whenever you want. Imagine crash reporting including let’s say 5 last states. How much easier it would be to reproduce bugs. Your QA team could even send you a timeline package when a bug is detected. You could literally replay the bug on your device.
Restoring state and easier development
Imagine you are working on a feature that requires navigation through several screens every time you start the application. With Redux you could simply dump the state and restore it at launch. You would be “teleported” there instantly. Nice, isn’t it?
Remote control
By having a Codable
state and actions you could easily implement a remote control for the application. Imagine there is some problem on your client’s device. You could simply send actions and observe the state remotely.
App recording 🔥
Having a single state for the whole application means you can easily store everything that happens within the app. You can record the whole timeline and then replay it, even keeping the original time intervals between actions. With Redux this can be implemented in 30 minutes and it will automatically support all new features as well! See how it works and check the implementation.
State hot reload 🔥🔥
Now the real magic begins. If we are able to serialize, deserialize and restore the state why not modify it outside of the application? The concept is really simple. We just dump the state to JSON file and we observe the file to load state when it changes outside of the application.
As a result, we get an amazing hot reload feature that allows modifying the state whenever we want. Imagine how much easier debugging UI and features it makes.
You may not believe it, but implementation takes about 30 minutes, it is just a single middleware. Check the code.
IMPORTANT! Do not edit JSON file in Xcode, because Xcode doesn’t modify the file, but deletes and replaces which breaks the file listener observing changes. I recommend using Visual Studio Code.
Replicating state on multiple devices 🔥🔥🔥
Still not enough? Check this out! If we can hot reload the state from a file, we can just put this file in a shared location, in my case I’m saving it on Desktop (it is possible if you are using a simulator).
Now you can open the same app on multiple simulators with different screen sizes or iOS versions and your every move in one application will be replicated on all others! You can even test simultaneously iPhone and iPad. If you save the file on iCloud storage you will achieve the same remotely on real devices!
How much easier it makes QA tests or even development to test layouts and app behavior on different devices.
Mind-blowing feature and it works out-of-the-box if you have a hot reload. And hot reload takes only 30 minutes to implement! Crazy, isn’t it?
No other architecture would let you implement such features that quickly and effortlessly.
Summary
As you can see Redux is a very powerful architecture yet quite simple in its concept. It provides a skeleton on which you can build amazing features like app recording, hot reload, or states replication. Those are only a few I thought about during my adventure with Redux. However, I’m sure you could build more interesting things on that.
Redux may require some effort at first to get used to a new way of development, but I think it is worth giving it a shot to see how much easier and more fun a development could be with the new possibilities.
Not every project will be suitable for Redux and for sure you will encounter some problems with this architecture (just like any other), so make sure that you understand the concept and do your research before starting an enterprise project using it :).
What’s next?
I tried to put here all the essential information so that you don’t have to navigate through different articles, but if you want to also read some more about Redux I can recommend you:
- The Complete Redux Book – very good free (you decide how much you pay) ebook, based on Javascript, but it helped me a lot to understand the concept.
- Advanced iOS App Architecture – interesting book from raywenderlich, it has one chapter about Redux and also an example application in UIKit using ReSwift library.
- Should We Bring Redux to iOS? – very good blog post about Redux with thoughtful pros and cons. Describes also different aproaches to asynchronous operations.
- Redux FAQ: Organizing State – nice FAQ answering common questions about state.
- Redux FAQ: Reducers – also worth to see.
- Redux Demo App – my demo application from this post, you can play with it, try to add new features like login screen. Just see how it is to work with Redux and decide whether you like it or not.