RedCat: Unidirectional Data Flow With Generic Functions

Dear community,

let me introduce RedCat - my take on unidirectional data flow.

Unidirectional data flow is the architecture pattern that is currently en vogue. It is the architecture pattern underlying frameworks like The Composable Architecture or ReSwift. Some have acclaimed this pattern (or The Composable Architecture in particular) to be the natural architecture for SwiftUI, although the architecture has been popularized by the javascript framework Redux and can be traced back to a lesser known language called Elm.

The idea is basically that one should treat the entire lifecycle of an App as a long list of commands that are reduced into a state given some seed value. The basic building blocks, reducers, therefore look like this: (Action, State) -> State. In RedCat, those functions are generally wrapped into structs:

struct Foo : ErasedReducer {
   typealias State = Int
   func apply(_ action: IncDec,
                     to state: inout Int) {
            ...
   }
}

The key benefit: if you have a non-trivial number of actions for your module or those actions are non-trivial, you can factor them out into methods of your reducer. Reducers thereby become self-contained, yet good to read standard types rather than just being a wrapper around a closure. Also, this makes it more pleasant to use safe and performant switch statements.

Here's the really cool thing: RedCat enables those safe switch statements even for higher level modules:

struct Dispatcher : DispatchReducer {

   @ReducerBuilder
   func dispatch(_ action: HighLevelAction) -> VoidReducer<MyConcreteRootState> {

         switch action {

             case .module1(let module1Action):
                 Module1Reducer().bind(to: \.module1).send(module1Action)

             case .module2(let module2Action):
                 Module2Reducer().bind(to: \.module2).send(module2Action)
                 .compose(with: someVoidReducerReactingToModule2Actions)
                 .asVoidReducer()

                 ...

         }
   }

}

As you can see, there's also the more traditional way of composing: two reducers consuming the same type of action can just be chained.

Another nice feature: ReducerWrappers!

struct Wrapper : ReducerWrapper {

    // especially useful for single action reducers
    // where the action is long, but can be separated into logical blocks
    let body = Reducer1()
               .compose(with: Reducer2())
               .compose(with: Reducer3()) 

}

Changing the state in reaction to user actions is fine, but ultimately, any useful app will have side-effects. RedCat implements those as decorators:

open class Service<State, Action> {
    
    public init() {}
    
    open func onAppInit(store: Store<State, Action>, environment: Dependencies) {}
    
    open func beforeUpdate(store: Store<State, Action>, action: Action, environment: Dependencies) {}
    
    open func afterUpdate(store: Store<State, Action>, action: Action, environment: Dependencies) {}
    
    open func onShutdown(store: Store<State, Action>, environment: Dependencies) {}
    
}

Services react to some basic app events and to each dispatched action. They get the opportunity to send actions back to the store either synchronously or deferred. Being somewhat agnostic to the "best" strategy, RedCat will call services right before and right after each action.

Another cool solution that you may have spotted: Dependencies! This is a completely dynamic, yet type-safe solution that imitates to an extent SwiftUI's EnvironmentValues. You can extend the values and objects as you like, the system makes sure that your services will see exactly those dependencies that you expect.

Did you ever want to submit a block of actions to the Store (the thing that represents the app and holds the reducer and the services) that will definitely execute in a specific order with no other action (enqueued synchronously by some Service) interfering with the execution of this block? In RedCat, you can just put your actions into an ActionGroup. RedCat's Store supports the "atomic" execution of ActionGroups in a way that you usually don't even have to think about it (disclaimer: you're still responsible for dispatching actions on the main queue; "atomic" just means that Store.send(ActionGroup(...)) cannot be messed with, while multiple calls to Store.send(<Action>) can).

Conveniently, if your action type is marked with the protocol SequentiallyComposable, you can write the above as

Store.send(AppAction.action1(...).then(.action2(...)).then(action3(...))

Oh, and by the way: Your actions can even conform to an Undoable protocol. While you need to provide an implementation of what it means to invert the action, RedCat automatically inverts UndoGroup<YourAction> for you!

There's a lot to discover, and I can't cover everything here. If you're curious, just check it out :slight_smile:

RedCat, following a minimalist and practical mindset, is slowly maturing. We haven't reached v1.0.0, so source breaking changes may still happen at this point. But I'm confident that with a little bit of community feedback I can declare the API stable. There's already a github issue that defines criteria for v1.0.0. Any feedback and contribution is highly welcome.

Happy coding!

2 Likes

Update:

I've tested the premise that the framework achieves static dispatch through optimization yesterday and found that this isn't the case. The code is written in a way that static dispatch of actions is possible, and it works for small enough reducer trees, but the compiler fails to optimize deeper reducer trees. I updated the framework in a way that reducers that don't respond to a given action will have empty function bodies after generic specialization (in optimized builds), but the compiler apparently cannot figure out that they are empty. Therefore, a lot of empty functions are called.

Hopefully we get a compiler version at some point that gives stronger optimization guarantees. The architecture of this framework has the potential to eke out a tiny bit of performance improvements over other Redux style frameworks, but only with this type of (theoretically possible) optimization.

Update of the update:

Enum typed actions that all the other frameworks use - probably without even wasting a thought - totally do achieve the efficiency I was looking for. Hence, RedCat will migrate to enum typed actions as everyone else does.

I do hope though that the new API remains as convenient as the current one. It was quite liberating not to waste too much thought on which actions a Reducer can consume.

Meanwhile, other features like the general purpose typesafe app dependencies or the dispatch reducer are noteworthy contributions.

Terms of Service

Privacy Policy

Cookie Policy