Last updated 23 min read

Building iOS Mobile Apps with Redux-like Architecture

One of the things I've been puzzling over recently. Can we benefit much from bringing redux-like architectures to iOS? How? And how can we get most of it?
Building iOS Mobile Apps with Redux-like Architecture
Photo by Raagesh C / Unsplash

Related: iOS App Development, Software Engineering


Can we benefit much from bringing redux-like MVI architectures to iOS and How can we get most of it?

Let's find out.

If you had a chance to watch one of the WWDC-2020 videos called Data Essentials in SwiftUI, you probably paid attention to the following image, describing the data flow in SwiftUI.

Actually, this is the exact illustration of the idea of a unidirectional data flow (UDF) architecture. SwiftUI suggests that the whole loop is taking place right in the UI layer of the app.

A couple of suggestions:

  • What if we could decouple that UDF pattern from UI layer, decompose and leverage it up to an app scale?
  • What if we introduce abstractions for all events so that every mutation in the app would be explicit and could be treated like a distinct transaction?

Actually, it has been done long ago in web apps. Furthermore, it has become so popular among web frontend devs that I have no idea how it managed to gather almost zero attention among iOS folks for a long time.

Origins

In 2014 Facebook introduced architecture design pattern which they called Flux. Flux was to tackle the complexities of managing the state in large-scale front-end applications.

Later the ideas used in Flux became the basement for tons of libs and frameworks for state management in web apps' frontend.

Redux turned out to be one of the most famous. So famous that it's often used as the name of the pattern along with UDF (unidirectional data flow architecture) or MVI (Model-View-Intent), or Elm which is a programming language with UDF in its mind.

Design Overview

Here is a list of components that are commonly used in Redux-like MVI architectures:

  • View
  • Props
  • Actions
  • State
  • Dispatcher
  • Reducer
  • Store
  • Side Effects

Views and Props

The view is to be implemented in particular way. It should data-driven with only one entry point, which is usually a render function with a single input that is called Props:

func render(_ props: Props) { ... }

In iOS terms in the case of a SwiftUI app we can use a view constructor instead of a render function due to the SwiftUI-specific view lifecycle:


extension SomeView {
    struct Props {
        let title: String
        let subtitle: String
        let selection: () -> Void
    }
}

struct SomeView: View {
    let props: Props

    var body: some View {
        VStack {
            Button(props.title) {
                props.selection()
            }
            
            Text(props.subtitle)
        }
    }		
}

In the case if UIKit we can either do it on the UIView level:


extension SomeView {
    struct Props {
        let title: String
        let subtitle: String
        let selection: () -> Void
    }
}

final class SomeView: UIView {
    private var props: Props?

    init(frame: CGRect) {
	    super.init(frame: frame)
	    //setup view here
    }

    func setProps(_ props: Props) {
        //update view here
    }
   
    private lazy var body: UIView = {
        //setup view body here
    }()
}

Or UIViewController:


final class SomeScreenViewViewController: UIViewController {
    private let body = SomeView(frame: .zero)
	
    func setProps(_ props: SomeView.Props) {
        body.setProps(props)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(body)
        //setup view body here
    }														
}


The main trick about data-driven View is that the entire view's state is represented by a single DTO that contains all prepared information that may be shown to the user directly without changes.

We sometimes call them "view models", but in the Redux world it's called Props.

The Difference Between Props & ViewModel

It's easy to confuse with MVVM's ViewModel where VM is a sort of a handy guy who is usually bound to a View, has a mutable state inside, executes some presentation logic, and can do many things.

In contrast, Props is simple data. Props are meant to be just lightweight view models DTOs.

  • If we need to handle user inputs or touch events we do it by closures that are passed as Props's properties.
  • If we need to do animations it's up to the View entirely: it may diff the old state with new Props and behave accordingly.

Complex UI components may be a composition of simple ones, the same can be said about Props.

Actions

Every event that is taking place within the app is wrapped into Action. User text inputs and taps, server responses, etc. They are sometimes called "Intents" in the MVI architecture pattern or simply "Events". It's all the same thing.

What matters is that we introduce a separate abstraction for every possible change in the app state, which acts like a transaction.

State

The core idea is to implement UI declaratively as a function of state. That's exactly what we are doing when we use SwiftUI.

In Redux, the single state is considered a data representation of the whole app. It may include not only business entities, auth state, etc but also UI and state of navigation. There are Flux-like alternatives, which suggest using multiple states that correspond to different features of the app.

Since state mutation is a bit expensive because it requires a round trip for actions dispatch and causes the UI update cycle, there are debates about: "What exactly should we put into the state?".

  • Some opinions insist on putting exactly everything into the store-managed state, only mutating it with actions, and keeping the rest of the app stateless.

  • Other devs suggest putting things into a store-managed state depending on their lifecycle and scope of use.

There are other state design caveats. For example, how to denormalize it and how to properly design the state of navigation.

Dispatcher

All UDF architectures are designed similarly.

Upper layers of the app, like UI cannot mutate the state directly. All they can do is send actions to a Dispatcher.

Originally, Flux was meant to have multiple stores with corresponding multiple states, so Dispatcher was intended to pass actions later on to Stores. The dispatcher handled prioritization and blocking dispatching. Some of the stores could wait for other stores to finish their work.

Redux is designed a bit differently. It's more simple. Since there is a single store the Dispatcher passes actions to the store and that's it. Store only ensures that actions are queued up and handled serially.

Reducer

Reducer is the only guy who knows how to mutate app state. Traditionally, a reducer is a pure function from state and action as input. It performs mutation and returns a new state.

This may sound a bit awkward, especially in a Redux-like single-state single-store case. As if we need to have a huge switch over all the app actions knowing how to mutate the whole app.

We don't. The app state can be a composition of other substates and the main app reducer can simply pass the actions to the substates' reducers.

So the whole thing can be quite clean:


struct AppState {
    private(set) var featureOne = FeatureOne()
    private(set) var featureTwo = FeatureTwo()

    mutating func reduce(_ action: Action) {
        featureOne.reduce(action)
        featureTwo.reduce(action)
    }
}

Store

The store is the guy who puts together the state, reducer, and dispatcher. It allows observers to subscribe and listen to the state updates.

When action is dispatched to the store, the action is applied to the state through reducer, and then the new state is passed to all store observers.

Side Effects

As you might have already noticed, it's not so obvious where we should put async work: network requests, database fetches and other I/O operations, timer events, location service callbacks, etc.

Things, that in the UDF world are called "side effects".

There are debates about where to put them, no single correct opinion, and several common patterns, that are applied in different combinations in all UDF architecture libraries.

The questions are:

  • Who should be responsible for side effects
  • Who should be responsible for executing side effects
  • How should the result be delivered

We need to choose a proper place to put the implementation of side effects, how we would execute them, and deliver the result of their work: resulting actions that should be dispatched back to the store.

Different Approaches to Side Effects

Async Executable Actions

One of the ways to handle side effects is to introduce a special kind of executable action, like AsyncAction. When dispatched it would be executed at some point, perform some async work, and then dispatch more actions when work is done.

Here is a simple example of how it may look like:


protocol AsyncAction: Action {
    func execute(dispatch: @escaping DispatchFunction)
}

struct FetchMoviesList: AsyncAction {
    let page: Int

    func execute(dispatch: @escaping DispatchFunction) {
        APIService.shared.fetchMoviesList(page: page) {    
            switch $0 {
            case let .success(response):
                dispatch(SetMovieMenuList(page: self.page,
                                        response: response))
            case let .failure(error):
                dispatch(MovieListFetchError(error: error))
            }
        }
    }
}

Async Action Factory: Action Creator

Async actions are likely to have dependencies, like API clients or database data sources. Following the [[dependency inversion principle]] we might want to provide dependencies to the action explicitly or somehow inject them.

Since actions can be created at any part of the app, including the UI layer, it may eventually become a mess because we need to pass actions' dependencies there.

Here is where GoF and creational design patterns kick in. In Redux toolkit, they use "Action Creator" – simple functions that init Actions. The approach can be quite beneficial for decoupling purposes for all kinds of actions (both plain and async).

The idea is to offload the responsibility for constructing Action to someone else. So that the actual "user" of the Action doesn't know how the Action is constructed or even what the exact type it is. It's all covered by the Action protocol.

ActionsFactory could be a candidate for that role:


protocol ActionsFactoryProtocol {
    func fetchMoviesList(page: Int) -> Action
}

struct ActionsFactory: ActionsFactoryProtocol {
    let apiService: APIService
    
    func fetchMoviesList(page: Int) -> Action {
        FetchMoviesList(apiService: apiService, page: page)
    }
}

struct FetchMoviesList: AsyncAction {
    let apiService: APIService
    let page: Int

    func execute(dispatch: @escaping DispatchFunction) {
        apiService.fetchMoviesList(page: page) {       
            switch $0 {
            case let .success(response):
                dispatch(MoviesListFetchSuccess(page: self.page,
                                                response: response))
            case let .failure(error):
                dispatch(MovieListFetchError(error: error))
            }
        }
    }
}

    

With that implementation, we can easily swap the factory and change async actions implementation for testing or whatever purposes without a need to touch the rest of the app:


struct LocalDBActionsFactory: ActionsFactoryProtocol {
    let dbClient: DBClient
    
    func fetchMoviesList(page: Int) -> Action {
        FetchLocalMoviesList(dbClient: dbClient, page: page)
    }
}

struct FetchLocalMoviesList: AsyncAction {
    let dbClient: DBClient
    let page: Int

    func execute(dispatch: @escaping DispatchFunction) {
        dbClient.fetchMoviesList(page: page) {       
            switch $0 {
            case let .success(response):
                dispatch(MoviesListFetchSuccess(page: self.page,
                                                response: response))
            case let .failure(error):
                dispatch(MovieListFetchError(error: error))
            }
        }
    }
}

Async Actions + Plain Middleware

Ok, now we have async actions and we need somebody who will execute them. For that purpose, we can introduce Middleware.

Let's put it in front of the store's reducer.

In that case it will intercept Async actions as soon as they are dispatched to the store and execute them before they get into reducer. The async work result actions will be dispatched to the store.

Since the async work implementation is carried out in the async action itself, middleware can be very minimal:


struct AsyncActionsInterceptor: Middleware {
    let dispatchFunction: DispatchFunction 

    func intercept(_ action: Action) {
        guard let action = action as? AsyncAction else {
            return
        }
           
        action.execute(dispatch: dispatchFunction)
    }
}
 

Plain Actions + Complicated Middleware

Another approach is to keep all Actions simple. Instead, we can make Middleware more intelligent.

We can allow middleware to intercept exact actions, decide which AsyncWork to perform and afterwards dispatch more actions as the result of its work.

We can scale it up and have many Middlewares where every one will be responsible for its own work.


struct MoviesMiddleware: Middleware {
    let apiService: APIService
    let dispatch: DispatchFunction 
     
    func intercept(_ action: Action) {
        switch action {
        case let action as FetchMovies:
            apiService.fetchMoviesList(page: action.page) {       
                switch $0 {
                case let .success(response):
                    dispatch(MoviesListFetchSuccess(page: self.page,
                                                    response: response))
                case let .failure(error):
                    dispatch(MovieListFetchError(error: error))
                }
            }
         default:
             break
        }
    }
}
 

Very Complicated Middleware

Middleware can be super intelligent. Instead of intercepting Actions, it may receive App's State and depending on it, decide which work to perform and dispatch Actions when it's done. This is how it works in ReSwift-Thunk.

State-Driven Side Effects

Another approach is almost the same as "Super intelligent middleware" that reacts to the state with only difference that we have no middleware at all.

Instead of middleware, we introduce Side Effect Drivers that are simply subscribers of the Store and receive state changes absolutely the same way as UI does.

If find this concept to be especially interesting.

By using Side Effects Drivers as Store observers we put them in the same row with UI. In a certain sense that means that we treat UI as a special special kind of side effect as well.

At the same time, it makes us implement side effects as a function of state meaning that at any point of time the amount of async work that our app produces is completely defined by its state.

That architecture approach draws an invisible boundary between the app's state and the outer world and that's just beautiful.

Reducers with Feedback Loops

Another approach to side effects suggests making reducers more complex. We can allow them to return new state + some feedback.

What is a feedback? It's up to implementation entirely. It can be anything, containing instructions for executing async work. Store should be capable to execute Feedback by means of middleware (or any other way) and dispatch result actions when task is done.


struct FetchMoviesList: Feedback {
    let apiService: APIService
    let page: Int

    func execute(dispatch: @escaping DispatchFunction) {
        apiService.fetchMoviesList(page: page) {       
            switch $0 {
            case let .success(response):
                dispatch(MoviesListFetchSuccess(page: self.page,
                                                response: response))
            case let .failure(error):
                dispatch(MovieListFetchError(error: error))
            }
        }
    }
}
    
struct FeedbackFactory {
    let apiService: APIService
    
    func fetchMoviesList(page: Int) -> Feedback {
        FetchMoviesList(apiService: apiService, page: page)
    }
}

struct Reducer {
    let feedbacks: FeedbackFactory
    
    func reduce(_ state: State, 
                action: Action) -> (State, Feedback?) {
        switch action {
        case let action as FetchMovies:
            let newState = .fetchingMovies
            let feedback = feedbacks.fetchMoviesList(page: action.page)
            return (newState, feedback)
        default:
           return (state, nil)
        }
    }
}
    
    

Side Effects Recap

As you can see, all side effects approaches are quite similar. All what we do is moving the same responsibility around and deciding who will be more dirty: either Action or Middleware or Reducer or somebody else.

Action Creators, Thunk, Saga in Redux

There are 3 most popular ways of working with SideEffects in the original Redux

  • Action Creators
  • React-Thunk
  • React-Saga

As I've already mentioned, action creators provides encapsulation of action init process.

React-Thunk is a kind of ActionCreator that encapsulates all async stuff under the hood and allows to create async actions in the same way as plain sync ones without exposure of any details.

React-Saga is a kind of intelligent middleware that intercepts plain actions and allows to perform complex sequences of work. It also allows to keep track of the workflow process, retry failed steps and so on.

Rather interesting that saga is actually a design pattern that came from distributed transactions world. More detailed info can be found here.

First impressions

For some reason when somebody talks about using Redux-like architecture in iOS it's often considered like weird experiment. And I'm not an exception in that sense. The first time I saw Yasuhiro Inami's talk I thought something like:

"well... interesting thing but seems like you are the only guy in the world who use it".

The first time I heard about Redux single state I say Vietnam war veteran's flashbacks of singleton special forces. Props seemed just yet another name for view models. Single source of truth and state changes observations made me smile because I often used CoreData and was happy to have all this out of the box.

Pros

Before we move to the main advantages list, let's introduce a short list of improvements that we can easily apply to our system, coded in Swift with Redux in mind.

Move everything to a single State

We will follow one of the opinions in the internet and will start to put in state, making presentational and the UI layer of the app stateless.

Actions as Plain DTOs

We will pick one of those approaches to produce side effects where sombody is intelligent and Actions are plain DTOs

Codable State

It will cost almost nothing to add auto-conformance to Codable protocol for the state.

Codable Actions

As long as our Actions are plain DTOs we can also make them conform to Codable. It might require some additional work and codegen with Sourcery, but it's absolutely worth it.

Multidirectional vs unidirectional data flow

I won't tell you how much more predictable and comprehensible the program is, when the flow is unidirectional comparing to a spaghetti with multiple states scattered all over the app.

Sounds ridiculously obvious but we really have no power to restrict the data flow direction in our app other than using UDF architectures.

We simply have no tool to control that. Even if we enforce ourselves to do it manually by just calling the right functions we are likely to fail as soon as the programmed system becomes complicated enough.

Fed up with spaghetti call stack? Tired of helpless attempts to make the data flow in one direction?

Immutable State

I won't tell you how much more predictable and comprehensible the program is, when all mutations happen and restricted to happen only in predefined places.

Suffer from unpredictable state changes and corrupted data?

Single Source of Truth

I won't tell you how much more predictable and comprehensible the program is, when when the rest of app is at most stateless, while the whole app state is consistent, defines everything what's going on in the app and explicitly contained in one place. Handling sudden changes in requirements for the app has never been so easy!

Sudden feature update requires a workaround to deliver synced state in different parts of the app?

Sudden rework of a feature now requires to pass the state from one end of the app to another?

Independent layers development

Redux-like architecture implies that that UI depends only on Props. So we can implement it independently from state or side effects. Not just implement but also preview and test it with different props as an input.

State and reducers can be implemented and tested completely independent from UI and Side effects.

Side effects. It depends on the exact way we choose to handle Side effects but basically we only need result Actions and some Core Services/Network Service stuff for them.

That all means that we can implement those layers almost independently from each other without need to spend additional time coding stubs.

Depressed because cannot work on different layers independently?

Tests without Redux

Usually We make a lot of effort firstly to make our code testable, then to test it and maintain tests later on.

If we need to test some decoupled Service, or tiny class, that's fine. Problems arise when we want to test something bigger, like cursed overcomplicated screen module.

Probably you've also found yourself looking at all those VIPER modules... and thinking that it's absolutely not a thing you want to write tests for...

Indeed, even properly decoupled screen module requires a lot of extra work to be done for testing.

The case

Let's assume that we want to test some complicated creation wizard or registration flow that involves several screen modules. That also implies data obtaining, some shared data between modules with variable navigation options.

It will either be untestable at all or it will require extensively decomposed app architecture.

Ok, we break it into tiny components and test them separately, but even if we have state-of-art testable Services + VIPER + Navigation Coordinator we will have to write mocks, spies and stubs for really a huge number of things.

The amount of work required to perform proper testing may be really overwhelming.

Conversely, that creates one more problem: such tests are not likely to be simple, compact and comprehensible. Maintenance of such tests suit would be even more painful than its initial implementation.

Tests with Redux

the pride of san francisco, golden gate bridge

Photo by Joseph Barrientos / Unsplash

In contrast Redux-like things is a great pleasure to test.

  • Any user behaviour, including navigation and inputs along with side effects results may be expressed by a sequence of actions.
  • No need to write mocks because state has no dependencies on services or side effects implementation.
  • Reducer is pure function without dependences, side effects or async work. The most testable guy in the world and the only guy we really need to test.
  • Tests are super simple: we just create state, mutate it with action through reducer and check the result
  • State can be decomposed down to small substates with tiny reducers. We can test them separately as cozy unit tests.
  • Side effects testability mainly depends on exact implementation but in general they are also rather test-friendly.
  • If we want to have some "integrational" test stuff we can test substates together in a larger state object. We can do it at any scale actually.
  • If we want to test presentational layer - we just create any State and test the generated Props
  • If we want to test UI - we generate any Props and then go on with XC tests UI capabilities
  • If we want to test UI with Snapshots - we generate any Props, make snapshots, compare, etc.

Redux-like architecture allows us to cover with tests all layers of our app, with exceptionally low effort. Probably the lowest effort I've ever seen.

Data Driven Tests with artifacts in Redux

As you remember, we've arranged to make the State and Actions Codable. That allows us to serialize them and turn into JSON in just a single line of code.

We can record any user flow by recording the sequence of actions and states. And that's mindblowing approach to testing the app.

Just imagine:

  • You launch the app in record mode.
  • Write down initial state
  • Perform some flow you want to test
  • Write down the sequence of actions
  • Write down the result state

Now you've got a full set of artifacts to test your flow.

Might I remind you that we are not talking about Xcode UI tests feature. That's absolutely viable data-driven testing wrap around unit tests for our business logic.

Advanced Debug with Redux

UDF, immutable state and single source of truth are the things that obviously help a lot in debugging.

Apart from this, Redux-like architecture brings debugging to some unreachable level.

Time travel debug

Time travel debug is about writing down the sequence of states and then switching between them back and forth as if you are Marty McFly.

It's a popular way to impress the audience and show the dominance of Redux-like architectures. However, I find it barely useful on practice.

Remote debug

Instead, I consider remote debug to be a much more helpful thing. Again, as we've got serializable State and Actions we can send them anywhere and use it for later investigations of bugs.

How can we use it on practice? We can trigger debug mode in the app and send states with actions to a remote debug server in real time. With the help of them we can reproduce bugs and use those sequences as artifacts for writing data-driven tests later.

Hot reload

During remote debug we can allow the app not just send its States/Actions but also receive them from server and apply/dispatch.

Great collaborative tools to work hand-in-hand with QA department, isn't it?

Redux-like + SwiftUI and UIKit

SwiftUI views are state-driven and it might be not very convenient to integrate them in an existing app with too much "imperative" and spaghetti directional data flow architecture.

SwiftUI connects to Redux perfectly. We can also easily build state-driven ViewControllers and Views and connect them to Redux or interop them with SwiftUI.

If we are developing a new app in December 2020 and SwiftUI may not seem to be very suitable for us yet. But we may expect to start using it in the upcoming year.

Redux might be a very good candidate to assist such transition.

Cons

At the very first site Redux could seem to be an ultimately flawless architecture design pattern. Could. But it doesn't. In contrast it arises a lot of questions right away.

At first sight it seems to be a silly joke on the whole objective oriented programming.

Indeed. The whole app architecture happens to be turned inside out with totally broken encapsulation which seems very unusual if not to say weird.

Huge, globally accessible, single state with a size of the app. God damn, what have you done to the the app?!

Scope

One of the questions arising when getting acquainted with redux-like stuff is: "What's the scope of the pattern?" Does it exist in UI layer and we should put it somewhere inside ViewController? Is it a replacement for VIPER?

It gets more confusing when many of UDF architecture libs for iOS suggest putting it at any scope you want.

You immediately think that you don't need yet another replacement for your sweetheart VIPER while building the whole app around UDF lib looks too much scary.

My opinion is: you can have whatever you want in the UI layer: MVC/MVP/VIPER built on UIKit or SwiftUI. UDF architectures work best for the whole app's scope state management.

Global Scope is Weird

Honestly, I'm so prejudiced about global/single/god objects that I still can't agree with myself about redux's single state. Just can't throw away the idea that it's definitely an antipattern that should be avoided by all means.

In its defense, I should note a few things.

First of all, global is not quite right word for the state. Actually it's not globally accessible from anywhere and it's only available for store's subscribers when it's delivered on change.

Secondly the state is significantly decriminalized by its immutability for all consumers. So yes, it's a kind of god object, but it's just a state without behaviour and without any ability for anyone to uncontrollably change it.

These two along with the fact that all changes happen only in predefined place are almost capable to convince me that it's possible to live with Redux's single state.

Single state pitfalls

Ok, even if we reach an agreement with ourselves about single state design, we may have problems that may arise when app grows large.

Fortunately, we have defusing kits for those bombs.

State Complexity

As soon as the app gets large enough figuring out anything from the single state will inevitably become a huge pain.

Flux <-> Redux

The idea of breaking the single state into multiple states with independent stores and going Flux way doesn't seem to be very attractive solution for me.

We may loose the many of very attractive Redux features and deal with possible headache from syncing of the state from multiple stores.

Decomposition

The first thing I'd start with is decomposition of the state into smaller substates while staying in the same single store.

Breaking the state by app features or by its functional units may work pretty well.

Facade & Interface Segregation

Decomposition will help for some time, but as soon as the complexity grows further it will be hard to get the needed data from the state. Hard because the whole state structure is huge and to get anything from it you'll have to know exactly what and where to look for.

The next step might be to put the whole state behind a facade with flat structure and hide all the complexity of getting data behind it.

The facade can be represented by just a protocol, which in turn may be a segregation of more simple protocols.

This step will allow to build the presentation layer of the app dependent not on the whole state, but on some specific parts of it, covered effectively with corresponding protocols.

In my opinion that would make the state more descriptive and even a huge app state can be turned into something more or less comprehensible.

Funny fact. We sometimes have to build facades in front of unmaintainable legacy code or some huge god objects but now I suggest building those god objects with facade in front by our own hands.

State Inconsistency

Another threat that always looms over the single state is the possibility to make it inconsistent. If we don't apply normalization to the entities, we can easily face data duplication that will turn the single state of truth into multiple sources of lie.

In order to fight it, we should store entity instances in the only part of the state. A kind of entity state repository which may be simply a key-value data struct. In all the other parts of the state, entity identifiers should be used.

When we need to obtain some entity on presentation layer or somewhere, we simply get it by id from the state repository.

Performance

How will the app perform in case of huge state? The whole app may mutate the state too often and trigger too many updates, delivered to the store observers. Which in turn may hit the performance.

Luckily, we have a lot of room for performance tuning.

  • If we face too frequent state updates that slow down UI, we can make props equatable and diff against them
  • SwiftUI performs diffs on its own, comparing view tree hierarchy by their bodies. Additionally we can mark Views as equatable and diff manually, based on props.
  • In UIKit it's also possible: we can diff against equatable props and reload UI only when necessary.
  • We can debounce and throttle at state observer level.
  • If props creation is slow for some reason, we can move those slow parts down to State and Reducer layer. Do heavy things in reducer and keep result in State.
  • We can perform reducer function on non-main thread queue (we actually should better do it by default) and don't affect main UI thread
  • If we hit responsiveness issues again we can profile reducer, spot the bottleneck of it and perform the computations as side effects.

State Size

Can huge state bring memory issues?

What we keep in state is plain data structs without any images or videos. Furthermore, the state avoids keeping data duplicates by normalization.

Frankly, I can hardly imagine the size of an app, whose plain data struct state will bring memory-related troubles on modern iOS devices.

However, if the state gets very large in size it may also have an overall negative performance impact so we should be ready to cut it.

Luckily our hands are not tied and we can invent optimisations for it. For example:

  • We can clean up state from fatty parts and those things that we often fetch from server. So we can treat a part of the state as a cache sort of things.
  • We can implement database storage for entities and perform fetch as side effect.

In my opinion, one of the most strong points of architecture built around the state is that it allows to implement and adopt significant upgrades on demand and avoid premature optimizations.

Caveats of Loose Coupling

Loosely coupled components bring not just a lot of freedom, but also great responsibility.

No matter how exactly do you handle side effects (middlewares, async actions or state-driven) upper layers of the app don't have dependency on side effects implementation.

On the one hand it allows to code UI and presentation layer, state and reducer completely independently from side effects.

On another hand, that means that you can simply miss or remove some side effect implementation and complier won't even remind you about it. You may forget to intercept and execute the right AsyncAction in your middleware easily.

This kind errors is easy to miss as the app doesn't crash or alert an error, it just doesn't perform network request when expected and keep quiet about it. It may be a big problem especially for big apps.

How to solve? Well, It's probably only Feedback-Loops-driven side effects that don't have such problem. For all the rest it can be only covered by tests I think.

State-Driven App as FSM

Using UDF redux-like architecture implies coding the app logiс as a finite state machine.

At first it seems very unusual. Then it turns into fun. Anyway that's rather different from common imperative programming and requires some effort and time to build those new neural pathways in our brain.

FSM is a trap

The main disappointment is when you got used to coding iOS app as FSM, you don't want to come back to imperative programming any more.

iOS developers fall in love only with abbreviation architecture design patterns.

So Redux won't become popular among us until we don't have a worthy decoding for its letters.

In my opinion Redux has a lot of undeniable advantages and cool features that easily outshine its cons, on small- and mid-sized projects. As I've already mentioned, its disadvantages on large scale apps are not dead-ends and usually have possible solutions.

Perhaps SwiftUI will make Redux more popular if the fashion doesn't drift to MVVM.

Conclusion

Everyone can now run through the infamous iOS architecture design checklist and decide if the architecture fits the following beliefs:

Is it scalable?

  • Does it handle changing business requirements efficiently?
  • Does it support gradual adoption in existing projects?
  • Does it allow replacing third-party components easily?
  • Does is encourage composition of small independent components?
  • Does it allow adding UI to features later?
  • Does it support breaking of retain cycles on integration rather than component level?
  • Does it support people working simultaneously on different parts of a feature?
  • Does it limit the use of event/notification systems to specific layers?
  • Does it provide a strategy for cutting corners and tech debt?

Is it maintainable?

  • Does it provide a tactic for fighting massive objects?
  • Does it limit the number of dependencies for an object?
  • Does it try to reduce the amount of boilerplate code?
  • Does it minimize the number of different roles of components?
  • Does it suggest project structure tolerant to changes?
  • Does it feel right?

Is it prepared for navigation?

  • Does it explain how to pass data between components?
  • Does it allow describing and discovering app flow naturally?
  • Does it avoid transient dependencies?
  • Does it define how to store state?
  • Does it minimize the amount of global state?
  • Does it allow opening a stack of screens?
  • Does it handle opening of a screen from a push notification?

Is it promoting quality?

  • Does it maximize the amount of testable code?
  • Does it encourages compile-time decisions over runtime decisions?
  • Does it avoid force casting and unwrapping?
  • Does it allow stubbing asynchronous code to run tests synchronously?
  • Does it support testing of UI?
  • Does it forgive mistakes?

Where to go from here?

I've made my own Redux Architecture library for iOS which is called Puredux

Here is a list of most famous iOS UDF libraries for inspiration: