composable-effect-identifier

0.0.1

A library that helps managing `Effect` identifiers when using "The Composable Architecture" (TCA)
tgrapperon/composable-effect-identifier

What's New

Initial Release

2022-02-01T07:58:34Z

Composable Effect Identifier

This ComposableEffectIdentifier is a small accessory library to The Composable Architecture (TCA). It allows to improve user experience when defining Effect identifiers.

It provides two tools to this end: a @EffectID property wrapper, and a namespace() higher order reducer that allows several similar stores instances to run in the same process without having to micro-manage ongoing Effect identifiers.

The @EffectID property wrapper.

When using TCA with long-lived effects, we need to provide some hashable value to identify them accross runs of the same reducer. If we start a timer effect, we need to provide an identifier for the effect in order to retrieve the effect and cancel it when we don't need it anymore.

Any Hashable value can be used as effect identifier. The authors of the library are recommending to exploit Swift type system by defining ad hoc local and property-less Hashable structs. Any instance of this struct is equal to itself, and collisions risks are limited, as these types are defined locally.

For example, inside some Reducer's block, one can define:

struct TimerID: Hashable {}

We can then use any value of this type as an effect idenfier:

switch action {
  case .start:
    return Effect.timer(id: TimerId(), every: 1, on: environment.mainQueue)
      .map { _ in .tick }
      
  case .stop:
    return .cancel(id: TimerID())
   
  case .tick:
    state.count += 1
    return .none
}

This works well. Calling TimerID() and creating a whole type when we simply need an Hashable value feels a little awkward though.

The @EffectID property wrapper allows to define indentifiers with an absolutely clear intent:

@EffectID var timerID

Accessing this value returns a unique and stable Hashable value that can be used to identify effects:

switch action {
  case .start:
    return Effect.timer(id: timerID, every: 1, on: environment.mainQueue)
      .map { _ in .tick }
      
  case .stop:
    return .cancel(id: timerID)
   
  case .tick:
    state.count += 1
    return .none
}

In order to be defined locally into some reducer, Swift >=5.4 is required (more precisely Swift >=5.5, as there is a bug with value-less local property wrappers in Swift 5.4).

By assigning some Hashable value to the property, you can augment the generated identifier with additional data:

@EffectID var timerID = state.identifier

Please note that @EffectID sharing the same user-defined value will not be equal if defined in difference places:

@EffectID var id1 = "A"
@EffectID var id2 = "A"
// => id1 != id2

The use of user-defined values can be even avoided most of the time when using namespaces.

Namespaces

With its current implementation, the core TCA library can be inconvenient to use in certain configurations, especially when developing document-based apps for example. In this kind of apps, each document is represented by a root Store. Each document should be unware of the existence of other documents opened at the same time. In such an app, many instances of the same type of root Store may run at the same time in the same process. When using local identifiers like Hashable structs in reducers to identify effects, one may create collisions, where one store instance may cancel an effect originating from another store (because ongoing effects are internally stored in a common, top-level, dictionary).

One solution would be to propagate some document-specific identifier in the State or the Environment, but this would require to append this identifier to every effect identifier in order to work properly. Furthermore, "leaking" such identifier in every unrelated feature goes a little again TCA core principle and impedes features isolation and reusability.

In the same spirit, composing a collection of features with ongoing effects is cumbersome too, as we need to inject some element-specific identifier to discern similar effects originating from different rows. Usually, row features are relatively simple, so the pervasion of the row's identifier is less perceptible, but it's still there, where the row feature should ultimately work in some list-agnostic context.

Fortunately, ComposableEffectIdentifier ships with a feature that helps greatly to solve this kind of issue. It works in conjunction and requires the use of the @EffectID property wrapper to declare effect identifiers. Any Reducer can be namespaced with some Hashable value. This values are used to augment @EffectID identifiers with contextual data (you can see it like the user-provided value, but coming "from the top"). Namespaces are propagated downstream along the Reducer's tree, and they compose with deaper namespaces.

Reducer namespaces

You namespace a reducer using the .namespace<ID>(_ id: ID) higher order reducer, which doesn't change the generic signature of the source Reducer. The id can be provided directly, or as a function or KeyPath from State or Environment. The id value should be constant for the branch, during all the execution of the program. For document-based app, you will most likely namespace the root-reducer with some stable value that identify uniquely the document.

Automatic namespaces

Two semi-overloads of the forEach pullback are provided. They are both named forEachNamespace, but they share the same arguments as their forEach counterparts otherwise. These reducers are working like forEach, but they're also namespacing their local reducers using the element's identifier (or dictionary key), siloing by this mean effects of each pulled-back reducer. For this reason, these local reducers can define their effect identifiers using the @EffectID property wrapper in isolation, without having to carry an external identifier.

Identified states

TCA already ships with an Identified wrapper that can wrap any value into an Identifiable value. The use of @EffectID leads to features that are becoming more and more agnostic of an external identifier. Because of this, it can be convenient to wrap the State of an identifier-less feature with the Identified wrapper, for example to include-it into an IdentifiedArrayOf<Identified<State, ID>>. As wrapping the feature to make it identifiable may be the only outcome of the procedure, an overload of forEachNamespace is also provided to directly pull-back, namespace and identify an identifier-less reducer in one call. This overload is available when the GlobalState is IdentifiedArrayOf<Identified<LocalState, ID>>, and the identifier-less reducer works on LocalState.

Example app

In order to demonstrate the power the namespaced reducers and @EffectID property wrappers, the library ships with an example app that pulls a neat trick: A LonelyTimer TCA feature is implemented. This feature handles a timer that count backward down to zero, with some start/stop functions. The LonelyTimer feature is unaware of the outer world. It handle its count, and that's it. It doesn't have an identifier itself. It has a name, but only by courtesy.

Around this LonelyTimer feature, an app called ManyTimers is built. This app handles an arbitrary number of timers, but without touching the code of LonelyTimer. The ManyTimers app furthermore ships in 4 flavors: an shoebox app, where a dozen of timers are hosted in a list at the same time, for iOS and macOS, and a document-based app, where each file handles one timer, again for iOS and macOS. The document based app can have several files opened at the same time, which is in some way similar as hosting them side to side in a list.

With both apps, several timers can run and be interacted with at the same time, each in isolation. Only one effect identifier is defined, at the LonelyTimer level.

Documentation

The latest documentation for ComposableEffectIdentifier's APIs is available here.

Installation

Add

.package(url: "https://github.com/tgrapperon/composable-effect-identifier", from: "0.0.1")

to your Package dependencies in Package.swift, and then

.product(name: "ComposableEffectIdentifier", package: "composable-effect-identifier")

to your target's dependencies.

Credits and thanks

The author (@tgrapperon) would like to especially thank @iampatbrown who gave the initial feedback that allowed to shape this library, and of course @mbrandonw and @stephencelis for the incredible work they put though TCA and their other amazing open-source projects.

License

This library is released under the MIT license. See LICENSE for details.

Description

  • Swift Tools 5.4.0
View More Packages from this Author

Dependencies

Last updated: Sun Mar 17 2024 14:44:06 GMT-0900 (Hawaii-Aleutian Daylight Time)