Swiftpack.co -  AnarchoSystems/RedCat as Swift Package
Swiftpack.co is a collection of thousands of indexed Swift packages. Search packages.
AnarchoSystems/RedCat
A unidirectional data flow library with an unorthodox mixture of static (reducer hierarchy) and dynamic (dependencies, services) features.
.package(url: "https://github.com/AnarchoSystems/RedCat.git", from: "v0.5.0")

RedCat

What is RedCat?

A red cat is not an ox!

RedCat is a unidirectional data flow framework with an emphasis on ergonomics. RedCat provides a couple of useful Reducer protocols that make composition of reducers quite readable. There are also a handful of concrete Reducer types that enable you to create Reducers with closures like in other such frameworks.

Examples

Reducers

The core component of every unidirectional data flow framework is the reducer. Here is one:

let incDecReducer = Reducer {(action: IncDec, state: inout Int in
   switch action {
      case .inc:
         state += 1
      case .dec:
         state -= 1
   }
}

The type of the above example will be inferred to Reducer<ClosureReducer<Int, IncDec>>. This is because in RedCat, reducers aren't just wrappers or type aliases for a certain type of closure, they are their own thing. Here's a more verbose way to achieve something similar to the above:

struct IncDecReducer : ReducerProtocol {
 
   func apply(_ action: IncDec, to state: inout State) {
      switch action {
         case .inc:
            state += 1
         case .dec:
            state += 1
      }
   }

}

let incDecReducer = IncDecReducer()

While more verbose, this may come in handy when dealing with more complex scenarios. Sometimes, an action cannot meaningfully be decomposed into subactions and the reducer function becomes lengthy. In this case, you want to factor code out into other meaningfully named functions. If reducers are defined by anonymous functions, it is not quite clear where to put those helpers. If they are self-contained structs, the helpers can just be private methods of the reducer.

Composing Reducers

Composing reducers is quite simple:

let reducer = myReducer1
                  .compose(with: myReducer2)
                  .compose(with: myReducer3)

The only prerequisite is that their state and action types agree.

Since currently there are no wrappers that support changing the action type, we recommend the more verbose spelling:


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()
                 ...
         }
   }

}

Note that this is also a bit more efficient than the usual composition of reducers with different action types, since the usual composition would destructure the action using if case (which is dynamic), while an exhaustive switch over an enum can be optimized. Also, it is safer, since you cannot accidentally miss a case.

Modularization

Of course, the main goal is to write reducers only for components of the state and then aggregate them into a reducer for the whole state. In order to make this easy, there are a couple of helpers.

For instance, you can bind a reducer to a certain mutable property of your state:

struct Foo {
   
   var bar : Int
   
   static let reducer = DetailReducer(\Foo.bar) {
      incDecReducer
   }
   
}

Or you can bind it to the associated value of an enum case

import CasePaths

enum MyEnum : Emptyable {

   case empty
   case bar(Int)
   
   static let reducer = AspectReducer(/MyEnum.bar){
      incDecReducer
   }
   
}

Here, we used a CasePath. Additionally, the enum has to conform to Emptyable or Releasable (parent protocol of Emptyable) to minimize the risk of triggering a copy-on-write.

Additionally, there's a way to bind the reducers implicitly to properties or cases while composing:

let structReducer = reducer1.compose(with: reducer2, property: \.foo)
let enumReducer = reducer3.compose(with: property3, aspect: /EnumType.bar)

Naming Schemes

Plain reducers, aspect reducers and detail reducers come in two flavors:

XXXReducerProtocol // protocol requires "apply" method
XXXReducerWrapper // protocol requires another reducer as "body"

and a struct XXXReducer. The plain Reducer can be initialized using a closure or using a keypath/casepath and a wrapped reducer. This makes Aspect/Detail in Aspect/DetailReducer optional, as the Reducer will then just wrap itself around the appropriate type.

For discoverability, we recommend adding reducer types, "namespaced" by their Statetype, as nested types to Reducers, a public "namespace" provided by RedCat.

The Store

If you're done composing the reducer, you may wonder how to make it do something useful. Unidirectional data flow frameworks are opinionated about this: There should only be one "global" app state, and the view should be a function of this. In order to make this work, there is a Store type.

In RedCat, this comes in two main flavours:

  1. The erased Store<State, Action> type seen by the services (see below).
  2. The ObservableStore<State, Action> that can be observed using addObserver. If Combine can be imported, this store is also known as CombineStore<State, Action>.

Actions are sent to the store via its send or sendWithUndo methods. This is assumed to happen on the main thread. The action will then be enqueued, sent to the services (which may or may not enqueue further actions), sent to the reducer (which mutates the global state) and then sent to the services again (which may again enqueue further actions). This process is repeated until no service has further actions to enqueue (or they enqueue them asynchronously). For this whole process, the observers are notified exactly once.

For discoverability, we recommend adding action types as nested types to Actions, a public "namespace" provided by RedCat.

Services

Every app has to handle side effects somehow. For this, RedCat has a dedicated Service class. This class exposes four methods that can be overridden: onAppInit, beforeUpdate, afterUpdate and onShutdown. The two methods reacting to updates take the dispatched action, the app's Dependencies (see below) and the state before or after the action is applied. The other two methods only take the store and the app's Dependencies.

The services are the perfect place to orchestrate further actions, either immediately or by registering an event listener and hopping back to the main queue whenever an event arrives. The Dependencies passed to the service are the ideal place to configure, e.g, the source of asynchronous events.

For discoverability, we recommend adding service types as nested types to Services, a public "namespace" provided by RedCat.

Environment

As mentioned above, services depend on some Dependencies type. This type works quite similarly to SwiftUI's environment, except it's named Dependencies in order to avoid name conflicts that break your property wrappers. The main way to work with this is as follows:

  1. You declare some type that will be used as a key for Dependencies' subscript:
enum MyKey : Dependency {
   static let defaultValue = "Hello, World!"
}
  1. You add an instance property to Dependencies:
extension Dependencies {
   var myValue : Int {
      get {self[MyKey.self]}
      set {self[MyKey.self] = newValue} //only required if you want to be able to change it
   }   
}
  1. Optionally, you set specific values when building the environment:
let environment = Dependencies {
   Bind(\.myValue, to: "42")
}

There's a noteworthy distinction to SwiftUI's Environment: The key type doesn't need to conform to Dependency. There's actually a more general version of this:

enum MyKey : Config {
   func value(given: Environment) -> Int {
      given.debug ? 1337 : 42
   }
}

This is very useful if the value usually only depends on other stored properties and only sometimes needs to be overridden.

Another key feature of Dependencies is memoization. Whenever the Dependencies instance doesn't find a stored value, it computes the default value - and stores it. This is done by reference (hence, the nonmutating getter), hence, if the associated value is a reference type, it will be retained. This is desirable whenever your dependency is designated for a service rather than a reducer. The only tradeoff is that reading environment values is not threadsafe and has to occur on the main thread.

Proofs of Concept

There are two toy projects showcasing how RedCat is used.

Installation

Swift Package Manager

In Package.swift, add the following:

dependencies: [
        .package(url: "https://github.com/AnarchoSystems/RedCat.git", from: "0.3.1")
    ]

Similar Projects

I took a lot of inspiration from the following projects:

Further Reading

GitHub

link
Stars: 4
Last commit: 2 hours ago

Ad: Job Offers

iOS Software Engineer @ Perry Street Software
Perry Street Software is Jack’d and SCRUFF. We are two of the world’s largest gay, bi, trans and queer social dating apps on iOS and Android. Our brands reach more than 20 million members worldwide so members can connect, meet and express themselves on a platform that prioritizes privacy and security. We invest heavily into SwiftUI and using Swift Packages to modularize the codebase.

Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API