How to cook reactive programming. Part 2: Side effects.
Maxim Smirnov
Posted on June 18, 2020
Despite the number, this is the third article about reactive programming. Today we are going to talk about how to handle side effects while using unidirectional approaches.
Before we start, I’d firstly highly recommend reading at least How to cook reactive programming. Part 1: Unidirectional architectures introduction.. However, if you’re not familiar with frameworks such as
RxSwift
orCombine
, or reactive programming in general, I’d suggest reading this article as well.
- What is Reactive Programming? iOS Edition
- How to cook reactive programming. Part 1: Unidirectional architectures introduction.
Intro
Before we will move to the talk about Side Effects
I want to introduce you to the main subject of this article.
This is a representation of the simplest State
which you actually can find in nearly every application. Let me transform this image into the real code.
enum State {
case initial
case loading
case loaded(data: [String])
}
Much better! If you’re familiar with a state machine theory from the computer science class, this picture will be very recognisable to you. This is a simple state machine with 3 states. The Initial
state can go to the Loading
state. The Loading
state to the Loaded
state. And the Loaded
state can go back to the Loading
state. Remember I was talking about State
data consistency. In this particular case it's really hard to make the state inconsistent. Each state of the system is represented as an enum case.
Don't worry, I'm not going to bother you with any computer science concepts here. It was mostly a representation of the ideal State
which could be achieved. In the real world it's really hard to create only an enum state. In most cases it would be a structure. However, for the purposes of this article we will use this State
for the experiments. And now let's move to the main topic.
What are Side Effects?
According to Wikipedia
In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the main effect) to the invoker of the operation. State data updated "outside" of the operation may be maintained "inside" a stateful object or a wider stateful system within which the operation is performed. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other side-effect functions. In the presence of side effects, a program's behaviour may depend on history; that is, the order of evaluation matters. Understanding and debugging a function with side effects requires knowledge about the context and its possible histories.
However, here we’re not talking about the strict definition of the side effects. Let's remember where we ended up the last time.
struct State {
var value: Int?
static func reduce(state: State, event: Event) -> State {
var state = state
switch event {
case .changeValue(let newValue):
state.value = newValue
}
return state
}
}
class Store {
var state: State
func accept(event: Event) {
state = reduce(state: state, event: event)
}
}
Generally, nearly all unidirectional architectures look like this. We have a State
on which the rest application relies as only one source of truth. A reducer
which alongside Event
is the only way to mutate or update State
. However, there should be something else. We don't live in a synchronous world, where every update for State
can be done only with a synchronous reduce
function. Every application needs to go to the network or database, for the new cat images. Every developer moves even hard computations on the background thread. So, how will all of this work with the existing code? The answer is side effects. In our case Side Effects
are something asynchronous, which could mutate State
and this "something" works on the side of the reducer
. Imagine your beloved network service which somehow needs to be connected to the rest of the system. But first let's talk about why this architecture is called ‘unidirectional’.
One remark: Event
in different implementations of unidirectional architectures could be called a Mutation
or Action
or Message
, or maybe something different, for our purposes however naming is not so important.
Why is the architecture called unidirectional?
Unidirectional architecture is also known as one-way data flow. This means that data has one, and only one way to be transferred to other parts of the application. In essence, this means child components are not able to update the data that is coming from the parent component. The main benefit of this approach is that data flows throughout your app in a single direction, giving you better control over it.
I think it should be quite easy to understand with theState
reduce
approach from the beginning. We can change or mutate State
only with a strict described Event
. As a result we've got a one-way (unidirectional) data flow. However, what should we do with Side Effects
?
Imagine that for the State
we have a service which as a result returns a list of the news titles.
func loadNewsTitles(completionHandler: ([String]) -> ()) {
completionHandler(["title1", "title2"])
}
We know that reducer
takes Event
as an input not a closure... How can we connect this service to the reducer
? The answer is quite simple. Let’s have a Side Effect
, which will return Event
, not just requested data.
The resulting system will look like this:
enum State {
case initial
case loading
case loaded(data: [String])
}
enum Event {
case dataLoaded(data: [String])
case loadData
}
func loadNewsTitles(completionHandler: (Event) -> ()) {
loadNewsTitles { data in
completionHandler(.dataLoaded(data: data))
}
}
extension State {
static func reduce(state: State, event: Event) -> State {
var state = state
switch event {
case .dataLoaded(let data):
state = .loaded(data: data)
case .loadData:
state = .loading
}
return state
}
}
As you can see for now loadNewsTitles
returns an Event
, which could mutate the state. Our system works only in a unidirectional way. And there's an answer to why the architecture is called Unidirectional
. After I’d answered one question, I've subsequently produced another one. How can we connect Side Effects
and the rest of the system? This question actually is the most complicated so far. I'll try to answer it in the next section.
Which types of side effects exist?
In nearly every unidirectional architecture
you'll see a collaboration of State
and some function for reducing
this State
according to the input Event
. With Side Effects
it's much more complicated. Almost every framework does this in a different way. Let me try to make you familiar with the most popular of them.
Middleware
Let's start with the Middleware
approach. Middleware
provides a third-party extension point between dispatching an Event
, and the moment it reaches the reducer. In simple terms Middleware
, sits in the middle between you performing or dispatching an Event
and mutating your State
inside the reducer
.
Let me provide you with a code example, which I found in one well-known framework for Redux
implementation for Swift.
let middleware: Middleware = { store, getState in
return { next in
return { event in
// perform middleware logic
switch event {
case .loadData:
loadNewsTitles { event in
store.accept(event)
}
case .dataLoaded:
break
}
// call next middleware
return next(event)
}
}
}
As you can see a Middleware
could be treated as an asynchronous preReducer
. It catches all Events
, carries out some manipulations over it - in our case, loading news titles -, and performs a new Event
for the system if it's necessary. So, if the Event
is loadData
, listed Middleware
will load news titles and in the closure send another Event
to the Store
. The next dataLoaded
Event
will just be ignored by this Middleware
. One of the pros of this method is the possibility to chain Middlewares
quite easily.
Also, if you want to read more about this approach, I’d highly recommend taking a look at ReSwift
framework. This framework is an implementation of a unidirectional architecture, which is called Redux
for Swift language. For those, who still refuse reactive frameworks, ReSwift
could be a good start, because ReSwift
doesn't use any.
Effects
The next approach I want to talk about is the Effects
approach. The main idea is almost the same as the Middleware
, but all actions are going on inside Reducer
itself.
In this approach Reducer
has a little bit of a different shape, that I showed you before. It has a shape, which you can see below.
func reducer(state: inout State, event: Event, environment: Environment) -> Effect
Nearly everything should be familiar. State
is a type that holds the current state of the application. Event
is a type that holds all possible events that cause the state of the application to change. However, there are two new characters: Environment
and Effect
. Environment
is a type that holds all dependencies needed in order to produce Effect(s)
, such as API clients, analytics clients, random number generators, and so on.
So, how does it work for our example?
struct Environment {
let loadNewsTitles: (Event) -> ()
}
struct Effect {
init(work: ((State) -> ())? = nil)
func performWorkItem() -> Event
}
extension Effect {
/// An effect that does nothing and completes immediately.
static let none = Effect()
}
extension Effect {
static func loadNewTitlesEffect(loadNewsTitles: (Event) -> ()) -> Effect
}
func reducer(state: inout State, event: Event, environment: Environment) -> Effect {
switch event {
case .dataLoaded(data: let data):
state = .loaded(data: data)
return .none
case .loadData:
return Effect.loadNewTitlesEffect(loadNewsTitles: environment.loadNewsTitles)
}
}
How does this approach work? For every call of Reducer
you provide all necessary dependencies via Environment
to the Reducer
itself, and afterward your Store
will perform every workItem
from the Effect
itself. And then every Effect
will return another Event
to the Reducer
. Unidirectional data flow works with all power here.
You may ask, but what about the pure Reducer
over there? You told us that Reducer
is a pure function, and now you put Side Effects
directly inside this function. Moreover, we mutate State
inside this function, not just creating a new value. So, I can definitely explain that this variation of the Reducer
is the most complicated one which we've seen so far. It has the Environment
inside and it mutates State
. However, let’s take a closer look. If we provide one implementation for the loadNewTitles
service, our Reducer
will perform the same and our State
in the end will be the same. Yeah, in the real world, our server can answer with the different replies or different news titles, but it still has the same output - Effect
as a return value, with the same network client in it. I hope you’ve got the idea. What about State
mutation? Since all real mutations are always going on inside the Store
, the main situation around changing State
hasn't changed itself. Moreover, mutating State
against creating new values for every reduce
saves some performance for us. We don't need to allocate new memory each time.
I don't want to provide a working example of this approach as well. My job is to make you familiar with it and explain the basics. However, I highly recommend taking a look at The Composable Architecture TCA
from pointfree.co. In my personal opinion this framework is the most promising for now. It has its own cons such as the minimum iOS 13 version. They also have a website with a lot of useful videos available on it. It's not free, but I have a promocode for you. I'm sorry I couldn't miss this chance...
Query Feedback
Let's move forward or downstairs. In contrast with Middleware
or Effect
approaches from the previous sections, there's a Query
approach. The Query Feedback
approach reacts
to new changes from the different side of the Reducer
compared to Middleware
.
Did you notice? We’ve moved the whole way through Side Effects
approaches? Middleware
was before Reducer
, Effects
approach was inside Reducer
and now Query Feedback
is after reducer. Quite a journey, huh?
However, how does it work? We need to take a small piece of the State
and start to Observe
every change of this state. For the previous example it will look like:
extension State {
var loadQuery: Void? {
guard case .loading = self else { return nil }
return ()
}
}
In other words, there's some kind of Observer
, which follows every change of this query and performs some actions over it. The optional Void
type for my taste is the best representation when you need to understand whether you need to do any work or not.
I think you’ve likely become bored with non-working examples in this article. So, let's try at least to implement this one. I'll use the Combine
framework for this implementation. Whoah, this is the third article about reactive programming, and only now I'll start to use a reactive framework. Also, afterward, I'll explain why I prefer to use Combine
over vanilla Swift. Technically Combine
is already vanilla as well, but you’ve got the point.
Here is a small table of concepts for those who are new in Combine
.
- Publisher declares that a type can transmit a sequence of values over time.
- @Published is a type that publishes a property marked with an attribute
- Sink attaches a subscriber with closure-based behavior to a publisher
- Cancellable a protocol indicating that an activity or action supports cancellation.
- Store stores this cancellable instance in the specified collection
From now a little bit of tutorial started. Everything that I'll write below you can copy to your project and play with it afterward.
Let's introduce our old characters: State
, Reducer
, Query
and Event
:
enum State: Equatable {
case initial
case loading
case loaded(data: [String])
}
enum Event {
case dataLoaded(data: [String])
case loadData
}
extension State {
var loadQuery: Bool {
guard case .loading = self else { return false }
return true
}
}
extension State {
static func reduce(state: State, event: Event) -> State {
var state = state
switch event {
case .dataLoaded(let data):
state = .loaded(data: data)
case .loadData:
state = .loading
}
return state
}
}
Nothing new so far - only a Query
which I've shown to you recently. Now let's remove the callback from loadNewsTitles
service and rewrite it in Combine
fashion.
func loadNewsTitles() -> AnyPublisher<[String], Never> {
["title1", "title2"]
.publisher
.delay(for: .microseconds(500), scheduler: DispatchQueue.main)
.collect()
.eraseToAnyPublisher()
}
Mostly it's just a pre-prepared mock with a small delay, which should simulate a real network environment. And now there's a new character in this play. Let's call it SideEffects
. Obviously it’s not me who invented this name, but let's imagine it for the bigger narrative of the story.
struct SideEffects {
let loadNewTitles: () -> AnyPublisher<[String], Never>
func downloadNewTitles() -> AnyPublisher<Event, Never> {
loadNewTitles()
.map(Event.dataLoaded)
.eraseToAnyPublisher()
}
}
As far as you can see, SideEffects
for the Query Feedback
approach is almost the same thing, as Environment
for the Effect
approach. I prefer to keep it as simple as possible, and most of the time it just converts the output from the services into Event
which could be consumed by the Reducer
.
And for now, there’s only one question left- how do we connect SideEffects
with the rest of the system? The answer isn’t so complicated, and Combine
helps with it very much. Let's build our boss Store
entity in which we'll connect every piece of our system.
typealias Reducer = (State, Event) -> State
class Store {
@Published private(set) var state: State
private let reducer: Reducer
private let sideEffects: SideEffects
init(
initialState: State,
reducer: @escaping Reducer,
sideEffects: SideEffects
) {
self.state = initialState
self.reducer = reducer
self.sideEffects = sideEffects
}
func accept(event: Event) {
state = reducer(state, event)
}
func start() -> AnyCancellable {
$state
.map(\.loadQuery)
.removeDuplicates()
.filter { $0 == true }
.map { _ in () }
.flatMap(sideEffects.downloadNewTitles)
.sink(receiveValue: accept(event:))
}
}
The most interesting part of the code listing above is the start
function. As far as you can see, I made our SideEffects
react to the change of the piece of the State
loadQuery
. And for every time when our system will be in the loading
State
, our SideEffects
will go to the network service, download new newsTitles
and notify our system that new titles have been downloaded. Do you see it? Everything in the cycle, all data flow works in the one direction.
Let's test what I've done so far.
let store = Store(
initialState: .initial,
reducer: State.reduce(state:event:),
sideEffects: SideEffects(
loadNewTitles: loadNewsTitles
)
)
var cancellables: [AnyCancellable] = []
store
.start()
.store(in: &cancellables)
store
.$state
.removeDuplicates()
.sink { state in print(state) }
.store(in: &cancellables)
store.accept(event: .loadData)
// Console output
// initial
// loading
// loaded(data: ["title1", "title2"])
And it works, as expected! The system started from initial
state
then it went to the loading
state
after the new Event
was sent, and ended up in loaded
state
. If you want to play with it some more, I've prepared a gist.
I know that two Cancelables
could look a little bit clumsy here, but I didn't want to make this example too complicated. There's another framework called RxFeedback
where all these problems were solved. I think that you've already got it, that this framework uses RxSwift
from the title, right? However, there's a constructor of Observable
- it's a Publisher
from the Combine
- which creates the whole unidirectional
system for you.
typealias Feedback<State, Event> = (Observable<State>) -> Observable<Event>
extension Observable {
public static func system<State, Event>(
initialState: State,
reduce: @escaping (State, Event) -> State,
feedback: Feedback<State, Event>...
) -> Observable<State>
}
Typealias Feedback
is a SideEffect
itself. It takes changes in the State
as an input and provides a sequence of Events as output
.
In my personal opinion, this approach is the most hardcore one. As an advantage, you can take that your State
always reflects what's going on in the system. The previous two rely on Event
while doing any Side Effects
, but this one only relies on the State
itself. If you want to read a little bit more about the pros and cons of the Effect
and Query
approaches, you could read my discussion with TCA creators.
Why do we need a reactive framework for this?
There are a lot of people who don't want to accept any reactive frameworks and don't understand why they are even needed. If you’re still reading this, and you are one of them, crash the like or clap button. This section is mostly for those who’ve been intrigued by the Unidirectional
approach, but for some reason don't want to use reactive
frameworks. Firstly I want to say, that you've already seen in my articles, that there’s nothing to be scared by inreactive
frameworks and reactive
programming in general. Most of you already use some techniques from it. I can say that it's much more handy to handle your data like a sequence
or array than work with enormous closures. Use some functions, like filter
, map
or reduce
etc. It really makes your code more clean and understandable. It's really hard to make a lot of mistakes from the start if you don't know how to cook it . That's why I write these articles for you.
Let me show you another advantage in a reactive framework usage. Do you remember that I relied on ReSwift
framework while showing a Middleware
approach? This is a great framework, which was written by brilliant people. However, if you try to understand how it works under the hood, or even try to work with it you will end up with structures like this.
/// Creates a middleware function using SimpleMiddleware to create a ReSwift Middleware function.
func createMiddleware<State: StateType>(_ middleware: @escaping SimpleMiddleware<State>) -> Middleware<State> {
return { dispatch, getState in
return { next in
return { action in
let context = MiddlewareContext(dispatch: dispatch, getState: getState, next: next)
if let newAction = middleware(action, context) {
next(newAction)
}
}
}
}
}
There are three closures inside each other! Moreover, I've used a helper to make it more simple. Of course, you could separate all of this somehow and avoid all the callback hell. However, if you take a look at my previous Query
example you will see how everything was simple and straightforward. I wanted to say elegant as well, but for elegance it has to be refactored a little bit . If you want to have a closer look at ReSwift
in action, I did a small test project some time ago.
Outro
There's no silver bullet on how to handle Side Effects
. You can decide for yourself what to use. However, I think that most of you and myself will choose some pre-prepared solution like RxFeedback
or TCA
or ReSwift
or something else.
Finally, I have a chance to show you this gif. I took it from the ReSwift
repo. This gif in full form represents the whole power of Unidirectional approaches
. Technically you can store the whole history of State
mutations and replay them at any moment you want.
So far, you've become familiar with how to work and even how to build your own reactive framework and unidirectional architecture. However, we live in a world where applications are not one button flashlight apps anymore. We have teams of more than ten people. And if you noticed, the main idea of Unidirectional architecture
is to keep all data inside one struct. I bet, if you start with this approach you will end up with a huge State
and Reducer
if not at the end of the week, then by at the end of the month. You may wonder how it's possible to separate the Unidirectional
approach on different modules when the main idea is to keep everything in one place. What to do in this situation and what are the ways of app modularization I will show you in the next article. Let's keep in touch!
If you don't want to lose any new articles subscribe
to my twitter account))
Twitter(.atimca)
.subscribe(onNext: { newArcticle in
you.read(newArticle)
})
Posted on June 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024