yakov.codes

Explaining flavors of Unidirectional Data Flows

Unidirectional state management/business logic implementation is a classic approach that keeps on living. Stemming from state machines, combined with modern languages and modern concepts, the result is interesting and many-faced.

Contents


What are they? #

Business logic can be stateful in settings where an application is long-running and changes based on external conditions. This could apply to applications where those conditions are a user or some stream of data. Prices of currencies, for example.

However, the example with a user is a standard application of some kind of abstraction that helps structure, control, and implement the logic behind it. Unidirectionals act as one of the many groups of those abstractions, following principles of immutability, data-first approach, and declarativity.

Data flows in one way, which usually can be summarised as Events/Intents in -> States out, and the cycle repeats – based on the states new events can be added, and so on. This can be contrasted with bidirectional approaches, like two-way bindings, for example.

A core part of all can be summarised as a reducer function

(State, Event) -> State

Why? #

Dealing with a state is hard, and dealing with a reactive state is even harder. Unidirectional approaches abstract out most of the low-level stuff – actual mutations, and allow for declaratively describing the relationship between intents and resulting states.

This approach can be generalized on (side) effects as well. This is tricky, however, as reducers, the functions that actually transform the current state and an incoming event to a new state, are pure, which obviously contradicts the whole point of side effects.

Let's not get ahead, as the main value is still present – declarative, seemingly immutable, data-first, simple functions that can fully encapsulate all the possible state changes. But that's not all – given that both states and events are represented as immutable data, algebraic data types, can be transformed and manipulated as any other data. Either by simple functions over data or as complex descriptions of asynchronous data streams – for filtering, manipulating concurrency, and much more.

For games, this is basically a must, as those complex state machines are most naturally represented as unidirectional, reducer-based abstractions. And for UI applications, such as websites and mobile applications, user behavior and some other parts are pretty naturally represented as well.

What about the boilerplate? #

Yep, lots of it. Unidirectional approaches absolutely bring relief to some of the hardest to deal with, and most time-consuming problems of user applications, but they also come with a tax of the boilerplate.

Redux is infamous for it, Elm is infamous for it, and a lot of libraries of the Android world are infamous for it. Boilerplate raises the complexity pointlessly, negating most of the benefits, and that's not even mentioning the raising of cognitive load, code duplication, the un-ergonomic and hard-to-debug

This is one of the reasons for an abundance of all the flavors, which is valid, but is not less of a pain to deal with. Naturally, developers try to maximize the benefits while minimizing the "price" of usage, and there have been quite a lot of improvements and development in the past n years.

Flavors #

Here are briefly listed most of the existing flavors, with some thoughts and code examples alongside. Examples are in Dart, but being a general-looking C-like language, this does not really matter; don't let that discourage you if you are not familiar with it.

I will use Events for describing Events/Actions/Intents for consistency, as they are (almost) the same thing in most of the flavors.

Direct reducer #

The simplest, and pioneering approach jumpstarted into the JS world and other worlds by Redux, fully resembling the pseudocode example above. Current state in, Event/Action in, new State out.

typedef Reducer1<State, Event> = State Function(State state, Event event);

Original Redux uses a single store which is not really relevant, as unidirectional approaches can utilize both multi-store and single-store approaches.

Side effects can be represented in many ways when using direct reducers, and many are listed below in their concept, just with different, arguably more ergonomic implementations. Currently, Redux evolved into a somewhat hybrid approach through RTK, but it is out of the scope of this article.

Redux and other such simple approaches make it hard to model side effects, even though there are a lot of ways that try to implement the less painful way. Not really effective.

Effects as data #

The next iteration starts the route of encoding side effects directly in the reducer. The second generation Elm pioneered this approach, which eventually seeped into Redux as well, and can be implemented in pretty much any language that allows passing functions as data in one way or another.

Just as JS has a native type for async data, Elm does this nicely, as it has a native type for side effects. Not necessarily asynchronous, but side-effectful. Simplifying this approach and encoding it in Dart, it can be formulated as something like this

typedef Output2<State, Event> = (State state, Future<Event> Function() effect);

typedef Reducer2<State, Event> = Output2<State, Event> Function(
  State state,
  Event event,
);

Reducer returns not only a new state but also a thunk of a side effect that returns a new, that is then evaluated and the resulting event is added back.

Works, works predictably, looks simple, and the concept itself is relatively easy to understand. Lots of boilerplate though, even in the Elm itself that supports this approach on the language level. Trying to port it to other languages yields even more verbose code which in turn leads to bloated, hard-to-understand, and repetitive codebases.

Effects as abstract data #

Even a more scary approach in terms of the amount of code, but beautiful in terms of abstraction power – reducers become very dumb and return very simple data structures that are then evaluated by interpreters.

This approach goes the full data-first route, modeling everything as data. Look at the definition of a reducer:

typedef Effect3<State, Effect, Event> = Future<Event> Function(
  State state,
  Effect effect,
);

typedef Output3<State, Effect> = (State state, List<Effect> effects);

typedef Reducer3<State, Event, Effect> = Output3<State, Effect> Function(
  State state,
  Event event,
);

Both sync and async effects are represented as synchronous, abstract data structures! Colored functions said hello, IYKYK.

It makes sense to give an example, but it will take an unimaginable amount of symbols to actually show even the simplest implementation in Dart, so pseudocode it is. Let's model some logging for a counter.

First, interfaces/data declaration

data State = Int

data LogLevel = Info or Warning

data Effect = (String message, LogLevel level)

data Event = Increment or Reset or None

After everything is encoded into data structures, they can be "evaluated" or "interpreted" by two functions, one pure – for state changes

State reducer(State state, Event event) {
  return when event
    Increment => (state + 1, [("Incremented", Info)])
    Reset => (0, [("Reset state", Warning)])
    Nothing => (state, [])
}

And one impure – for effect evaluation.

Event effect(State state, Effect effect) {
  print(effect)
  
  return None
}

This is a very powerful abstraction that fully encapsulates all the implementation details, but even in this hypothetical pseudocode language, it is verbose and scales as good as Redux. Not good.

Effects as a part of a reducer #

A logical extension of those approaches is just mashing side effects and state changes together. Sounds a bit sad, as we are losing the determinative aspect of the reducer, but it is the most ergonomic approach of all the mentioned previously.

Two examples come to mind, which are BLoC as defined in the awesome article of Didier Boelens, and PureScript's Halogen.

The main idea can be formulated as "after the reducer receives a new event, it can perform any asynchronous, effectful actions while returning as many states in the process as required". This makes implementing complex effectfull flows, such as pagination, very easy and ergonomic, while maintaining the data-first approach, and consequentially, all the benefits from treating both intents and resulting states as values.

Returning to BLoC, the reducer type definition looks far simpler than other approaches

typedef Reducer5<State, Event> = Stream<State> Function(
  State state,
  Event event,
);

Stream is just an asynchronous collection, which fits the definition of a reducer that can perform async actions and return multiple states in the process.

Effects as reactive units #

Lastly, an approach that was prevalent in the Android world some few years ago, effects can be described as reactions to state changes or events, decoupling them from the otherwise pure reducer that does not know absolutely anything about side effects being performed.

This approach is conceptually similar to Redux's Middlewares, which can be categorized as the same MVI.

This is a bit of a recursive definition since the effect evaluation is an effect in itself in this paradigm – a subscription to internal events of the "Store". ClojureScript's Re-frame generalizes this even further, and Redux's Saga is similar in the sense that it returns an asynchronous sequence of events to be added back.

Going back to Dart, this definition looks similar to the previous one and the very first – it combines a pure reducer with an impure async collection, with the exception that the collection describes not new States, but new Events

typedef Reducer6<State, Event> = Reducer1<State, Event>;

typedef Watcher6<State, Event> = Stream<Event> Function(
  State previousState,
  State nextState,
  Event event,
);

Closing words #

What is the conclusion here? Mostly, I would say that the diversity of unidirectional flow-centered approaches says a few main things about their current state:

  1. They are battles tested and proven to work and work well
  2. Developers are dissatisfied with current approaches, particularly with Developer Experience that they offer
  3. Some languages are more fit for some flavors, particularly if they support parts on the language level. Command in Elm and async generators in Dart and JS are examples of that
  4. Some pioneering approaches combine the flavors, which is a topic for another article
  5. Immutability, declarativity, and data-first approaches are useful in many ways but come with a cost