Last time we were talking about different types of Side Effects
, and moreover I showed you some libraries which support Unidirectional data flow
approaches. I can certainly say if you’ve already read all the previous articles you now have a clear idea of how reactive programming
works, and how to build a unidirectional
architecture by yourself.
I would highly recommend firstly reading at least the two previous articles. However, if you’re not familiar with frameworks such as RxSwift
or Combine
, or reactive programming in general, I’d suggest reading my first article as well.
- What is Reactive Programming? iOS Edition
- How to cook reactive programming. Part 1: Unidirectional architectures introduction.
- How to cook reactive programming. Part 2: Side effects.
As I mentioned in my previous article, we live in a world where applications are not one button flashlight apps any more. We have teams of more than ten people. And if you noticed, the main idea of Unidirectional architecture
is to keep all data in one place. I bet, if you start with this approach, you’ll end up with a huge State
and Reducer
if not at the end of the week, then certainly by 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. That’s exactly what I'm going to show you today.
Today I will show you two approaches for Unidirectional architecture
modularization. I picked these two not because there are not more out there - you can easily find them if you search the internet. I picked them because they are opposite to one another. As a starting point, I picked a module that I want to use for experiments from the previous chapter.
struct User {
let id: AnyHashable
}
struct NewsState {
var user: User
var step: Step
}
extension NewsState {
enum Step {
case initial
case loading
case loaded(data: [String])
}
}
enum NewsEvent {
case dataLoaded(data: [String])
case loadData
}
extension NewsState {
static func reduce(state: NewsState, event: NewsEvent) -> NewsState {
var state = state
switch event {
case .dataLoaded(let data):
state.step = .loaded(data: data)
case .loadData:
state.step = .loading
}
return state
}
}
But this time we need an app, where all the magic will happen. I’d like to keep it as simple as possible.
struct AppState {
var user: User
}
enum AppEvent {
case newUserWasLoaded(User)
}
extension AppEvent {
static func reduce(state: AppState, event: AppEvent) -> AppState {
var state = state
switch event {
case .newUserWasLoaded(let user):
state.user = user
}
return state
}
}
Imagine you have a small module like I've shown you recently, which can download news titles. Let's just name it News
. And you have the app itself, let's just call it App
. Quite a simple module I’d say, but this time we need a User
who’s shared within the whole app. Moreover, let's add a situation when the main app should know about the loading step from the news module, for example, it has another page, which depends on the news titles. So, let's start with the first approach on how to connect it to the main app. A list of requirements would look like this:
- The
App
needs to know about all changes in theNews
- The
News
needs to know about all changes in the part of theApp
(User
)
I think these requirements are simple and are enough for showing modularization concepts. So, let's start with the first approach.
Composable Reducers
If you read my previous article, I bet you've already understood what architecture I'm going to talk about. Composable Architecture provides a really good way for modularization. What do they do?
The main idea of the modularization with the composable approach is to store every Event
and every State
in the App
module. Let's change the app a little bit to conform to the new requirement.
struct AppState {
var user: User
var newsState: NewsState?
}
enum AppEvent {
case newUserWasLoaded(User)
case newsEvents(NewsEvent)
}
So far, I've just put everything inside the App
itself. We’ll talk about problems with this approach a little bit later. For now, we need to proceed and solve the next problem. How would this system work? Maybe you've noticed that I omitted reducer
for the new app variation. I did this on purpose because the entire work which should be done exists in the reducer itself.
Let's try to solve this problem as it stands with building a new reducer.
extension AppEvent {
static func reduce(state: AppState, event: AppEvent) -> AppState {
var state = state
switch event {
case .newUserWasLoaded(let user):
state.user = user
case .newsEvents(let event):
switch event {
case .dataLoaded(let data):
state.newsState?.step = .loaded(data: data)
case .loadData:
state.newsState?.step = .loading
}
}
return state
}
}
It's a little bit clumsy, but it works. However, I've just generated another problem. NewsState
is optional and should be created somewhere. Let's try to create it the first time when .newsEvents
have appeared. The final reducer
would look like this:
extension AppEvent {
static func reduce(state: AppState, event: AppEvent) -> AppState {
var state = state
switch event {
case .newUserWasLoaded(let user):
state.user = user
case .newsEvents(let event):
if state.newsState == nil {
state.newsState = NewsState(user: state.user, step: .initial)
}
switch event {
case .dataLoaded(let data):
state.newsState?.step = .loaded(data: data)
case .loadData:
state.newsState?.step = .loading
}
}
return state
}
}
Now the system works. However, it doesn't look like a modularization at all. All actions happen inside one function in the main application. Let's try to fix it a little bit. We've already had a reduce
function for the News
module. I want to try to adopt it as is to the previously implemented reducer.
extension AppEvent {
static func reduce(state: AppState, event: AppEvent) -> AppState {
var state = state
switch event {
case .newUserWasLoaded(let user):
state.user = user
case .newsEvents(let event):
if state.newsState == nil {
state.newsState = NewsState(user: state.user, step: .initial)
}
state.newsState = NewsState.reduce(state: state.newsState!, event: event)
}
return state
}
}
Now we have the composition of the most primitive function. It looks better, but it’s still not what we would expect from modularization. Could we somehow improve this composition? Of course we can - in the end, I'm not inventing here anything.
I’d suggest starting with the functions declaration first. What do we already have? We have two reducers, which work like one reducer. What does that mean? It means we want a function which could compose
two separate reducers
into one. So, for the first step, we need a reducer declaration.
typealias Reducer = (AppState, AppEvent) -> AppState
Just a type alias for the function, which takes State
and Event
as the input and provides a result state as an output. Ok, so there’s nothing new here. For the next step let's make a declaration for the combine
function, which combines multiple reducers into one.
func combine(_ reducers: Reducer...) -> Reducer
Things are progressing easily so far. Now with these declarations, we’ll write a realization for the combine
function. We just need to chain every reducer with the provided state and event.
func combine(_ reducers: Reducer...) -> Reducer {
return { state, event in
reducers.reduce(state) { state, reducer in
reducer(state, event)
}
}
}
This function could be realized with a simple “for” loop, however, the usage of the reduce
function inside our combine
looks more symbolic, doesn't it? Let's try out what we've done so far.
let firstReducer: Reducer = { state, event in
var state = state
switch event {
case .newUserWasLoaded(let user):
state.user = user
default:
return state
}
return state
}
let secondReducer: Reducer = { state, event in
var state = state
switch event {
case .newsEvents:
state.newsState?.step = .loading
default:
return state
}
return state
}
let resultReducer = combine(firstReducer, secondReducer)
let initialState = AppState(user: User(id: ""), newsState: NewsState(user: User(id: ""), step: .initial))
let firstMutation = resultReducer(initialState, .newUserWasLoaded(User(id: 1)))
let secondMutation = resultReducer(firstMutation, .newsEvents(.loadData))
print(secondMutation.user.id)
print(secondMutation.newsState!.step)
// Console output:
// 1
// loading
What have I just done? I've created two reducers, combined them into one result reducer, and performed two mutations. As a result you can see that two completely separated reducers mutate one state, which is a proof of concept. However, I created another problem, and I've started to have the feeling that I'm only creating problems, not solving them. The combine
function can work only over one type of state - AppState
- and one type of event - AppEvent
. We have the News
module, which has different state and event types. How could this situation be resolved? Maybe you've already guessed, according to my first iterations, that NewsState
, now a part of AppState
and NewsEvent
, is a part of AppEvent
and we could apply some conversions to fulfill combine
function requirements. But what should we do exactly? We need to convert NewsReducer
into AppReducer
.
NewsReducer
has this declaration:
func reduce(state: NewsState, event: NewsEvent) -> NewsState
And AppReducer
this:
func reduce(state: AppState, event: AppEvent) -> AppState
I want to split this task into two smaller ones. Let's start with transforming
func reduce(state: NewsState, event: AppEvent) -> NewsState
into
func reduce(state: AppState, event: AppEvent) -> AppState
As usual I want to start with a function definition:
func transform(newsReducer: (NewsState, AppEvent) -> NewsState) -> Reducer
Because Reducer
is a function itself, we need to build a function inside another function 🤯. The first step will look like this:
func transform(newsReducer: (NewsState, AppEvent) -> NewsState) -> Reducer {
return { appState, appEvent in
???
}
}
To build a function which uses AppEvent
and AppState
we have NewsState
and AppEvent
. What can we do here? Because we need to put NewsState
inside NewsReducer
we need to somehow transform AppState
into NewsState
:
func transform(
newsReducer: (NewsState, AppEvent) -> NewsState,
appStateIntoNews: (AppState) -> NewsState
) -> Reducer {
return { appState, appEvent in
let newsState = appStateIntoNews(appState)
return newsReducer(newsState, appEvent)
}
}
We’re Halfway there, but it's not enough, as we've got an error
Cannot convert return expression of type 'NewsState' to return type 'AppState'
It means that we need somehow to transform the ews reducer result into AppState
. We already know that NewsState
is a part of the AppState
, so it shouldn’t be so hard.
func transform(
newsReducer: @escaping (NewsState, AppEvent) -> NewsState,
appStateIntoNews: @escaping (AppState) -> NewsState
) -> Reducer {
return { appState, appEvent in
var appState = appState
let newsState = appStateIntoNews(appState)
let newNewsState = newsReducer(newsState, appEvent)
appState.newsState = newNewsState
return appState
}
}
It will work, I can assure you. However, it doesn't look generic enough for usage for different applications with different state interfaces. So let's fix it.
func transform(
newsReducer: @escaping (NewsState, AppEvent) -> NewsState,
appStateIntoNews: @escaping (AppState) -> NewsState,
mutateAppStateWithNewsState: @escaping (AppState, NewsState) -> AppState
) -> Reducer {
return { appState, appEvent in
let newsState = appStateIntoNews(appState)
let newNewsState = newsReducer(newsState, appEvent)
return mutateAppStateWithNewsState(appState, newNewsState)
}
}
Much better, but we could refactor it a little bit more with a KeyPath
usage, and decrease the number of input parameters of the function, so let's try it out.
func transform(
newsReducer: @escaping (NewsState, AppEvent) -> NewsState,
stateKeyPath: WritableKeyPath<AppState, NewsState>
) -> Reducer {
return { appState, appEvent in
var appState = appState
appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], appEvent)
return appState
}
}
That's what I’m talking about. Let's move forward, we still have unfinished business according to events. The desired transform
function will look like this:
func transform(newsReducer: (NewsState, NewsEvent) -> NewsState) -> Reducer
Let's start with an update to the existing one:
func transform(
newsReducer: @escaping (NewsState, NewsEvent) -> NewsState,
stateKeyPath: WritableKeyPath<AppState, NewsState>
) -> Reducer {
return { appState, appEvent in
var appState = appState
appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], appEvent)
return appState
}
}
This code will show another error:
Cannot convert value of type 'AppEvent' to expected argument type 'NewsEvent'
As far as you can see a Swift compiler really helps in these kinds of things, as throughout I'm just doing whatever it says to me. And now we need to convert AppEvent
into NewsEvent
. We've already made one as a part of another. Now we should make one small addition to make things easier.
enum AppEvent {
case newUserWasLoaded(User)
case newsEvents(NewsEvent)
var newsEvent: NewsEvent? {
guard case .newsEvents(let event) = self else { return nil }
return event
}
}
We have a newsEvent
property, which allows us to easily convert AppEvent
into NewsEvent
if it's possible. I'm going to add this update and fix the error.
func transform(
newsReducer: @escaping (NewsState, NewsEvent) -> NewsState,
stateKeyPath: WritableKeyPath<AppState, NewsState>
) -> Reducer {
return { appState, appEvent in
guard let newsEvent = appEvent.newsEvent else { return appState }
var appState = appState
appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], newsEvent)
return appState
}
}
In this case, if the app event was a news event we will perform a news reducer. However, the same small change should be added to make the transform
function more generic. What have we done so far? We converted AppEvent
into NewsEvent
, so should we just add a conversion function as another parameter of the transform
function?
func transform(
newsReducer: @escaping (NewsState, NewsEvent) -> NewsState,
stateKeyPath: WritableKeyPath<AppState, NewsState>,
toNewsEvent: @escaping (AppEvent) -> NewsEvent?
) -> Reducer {
return { appState, appEvent in
guard let newsEvent = toNewsEvent(appEvent) else { return appState }
var appState = appState
appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], newsEvent)
return appState
}
}
Perfect! Now we have a transform function, which will really help with the reducers composition. Shall, we add the final step to make transform
and combine
into generic functions?
func combine<State, Event>(_ reducers: (State, Event) -> State...) -> (State, Event) -> State {
return { state, event in
reducers.reduce(state) { state, reducer in
reducer(state, event)
}
}
}
func transform<GlobalState, GlobalEvent, LocalState, LocalEvent>(
localReducer: @escaping (LocalState, LocalEvent) -> LocalState,
stateKeyPath: WritableKeyPath<GlobalState, LocalState?>,
toLocalEvent: @escaping (GlobalEvent) -> LocalEvent?
) -> (GlobalState, GlobalEvent) -> GlobalState {
return { globalState, globalEvent in
guard
let localEvent = toLocalEvent(globalEvent),
let localState = globalState[keyPath: stateKeyPath]
else { return globalState }
var globalState = globalState
globalState[keyPath: stateKeyPath] = localReducer(localState, localEvent)
return globalState
}
}
Voila! So it’s a small change, but we can use these functions for every reducer right now. Let's test them with our previous test case.
let appReducer: Reducer = { state, event in
var state = state
switch event {
case .newUserWasLoaded(let user):
state.user = user
default:
return state
}
return state
}
let combinedReducer = combine(
transform(
localReducer: NewsState.reduce(state:event:),
stateKeyPath: \AppState.newsState,
toLocalEvent: \AppEvent.newsEvent
),
appReducer
)
let firstMutation = combinedReducer(initialState, .newUserWasLoaded(User(id: 1)))
let secondMutation = combinedReducer(firstMutation, .newsEvents(.loadData))
print(secondMutation.user.id)
print(secondMutation.newsState!.step)
// Console output:
// 1
// loading
The output is the same as on the previous occasion, however we now have a fully generic approach to achieve this. And that's it, we've achieved a fully generic way of Unidirectional
architecture modularization. It's actually quite a straightforward way to modularize your system. Before we move forward to another modularization approach, let's talk a little bit about the pros and cons.
Pros:
The true way of Unidirectional
data flow. You don't lose any of the advantages of the unidirectional approach and as far as you can see the main mantra still applies - everything is in the one place, everything works in the one direction, everything is consistent. It goes from the previous point, there's no way - unless of course you deliberately make one - when you have for instance different users in different modules.
Cons:
Not true module separation. To achieve this modularization approach you need to reveal the State
and Event
of the underlying module to the module where you attach things. It shouldn't be a big problem if all your modules work together only in one project or several affiliated projects. However, it could cause some problems in two cases. The first case is if you just have started to use unidirectional approaches in your codebase. I bet you want to isolate and reuse modules in this case, not connect them in one place. In the second case when you work over a framework, you shouldn’t expect your customers to adopt a way of modularization, which I've just shown to you.
As I've mentioned before, what I've shown you I took from the pointfree channel. I've tried to make things a little bit different. They separated the app by modules, but I attached the existing module to the app. It’s not a big difference, but if you want to go deeper consider watching their videos as well. Also, they've explained a lot of functional programming concepts, which we used to achieve this modularization approach.
Fully separated approach
Do you remember what were the problems of the previous approach? That it’s hard to use in isolation and you have to reveal a state and event for an upper module. With this approach, you can solve these problems.
For this time we need to remember about Store
.
class NewsStore {
@Published private(set) var state: NewsState
private let reducer: (NewsState, NewsEvent) -> NewsState
init(
initialState: NewsState,
reducer: @escaping (NewsState, NewsEvent) -> NewsState
) {
self.state = initialState
self.reducer = reducer
}
func accept(event: NewsEvent) {
state = reducer(state, event)
}
}
Why do we need Store
here so badly? Because Store
is an entry point of the whole module creation. We actually need it for Composable Reducers
, but it’s not so bad and I wanted to save a little bit of your time.
So, how do we achieve modularization? The answer is actually more straightforward or even easier than with a previous approach. We just need to provide a way of updating information from the outside world and provide a way to notify changes from the child module. With any reactive framework, it's a super-easy task. So, let's implement it in one step.
class ViewController: UIViewController {
var cancellables: Set<AnyCancellable> = []
}
enum InputEvent {
case newUserWasLoaded(User)
var user: User {
switch self {
case .newUserWasLoaded(let user):
return user
}
}
}
enum OutputEvent {
case newsStateStepWasUpdated(NewsState.Step)
}
class NewsStore {
@Published private(set) var state: NewsState
private let input: AnyPublisher<InputEvent, Never>
private let reducer: (NewsState, NewsEvent) -> NewsState
init(
initialState: NewsState,
input: AnyPublisher<InputEvent, Never>,
reducer: @escaping (NewsState, NewsEvent) -> NewsState
) {
self.state = initialState
self.reducer = reducer
self.input = input
}
func accept(event: NewsEvent) {
state = reducer(state, event)
}
func start() -> (UIViewController, AnyPublisher<OutputEvent, Never>) {
let viewController = ViewController()
input
.map(\.user)
.sink { self.state.user = $0 }
.store(in: &viewController.cancellables)
let output = $state
.map(\.step)
.map(OutputEvent.newsStateStepWasUpdated)
.setFailureType(to: Never.self)
.eraseToAnyPublisher()
return (viewController, output)
}
}
What are all the nteresting things that happened in the Store
? I've just added two publishers Input
and Output
. With input, I can observe what happened in the outside world of the module and update data, according to the input event. With output, I can cut a piece of the state, which could be interesting in the outside world. Also, there’s one small addition because the sink
method produces Cancellable
type, so we need to somehow utilize this sink
subscription. I've attached it to the lifecycle of the view controller however, it could be done in different ways since unidirectional architecture could be used not only in the partnership with a view.
The second approach part ended up much shorter than the previous one. I hope it's because this approach is far easier to understand and to implement, not because I'm too lazy to write it step by step. Now let's talk about the pros and cons of this approach.
Pros:
True modularization. An application doesn't depend on the underlying module realization. If you want to try unidirectional approaches, it's a way to adopt this as an only part of the project. Also, it’s suitable for SDKs creation.
Cons:
It’s too easy to create the not true unidirectional state, because modules could have their own independent states and the whole app state could be inconsistent. Because it's a state-based system, sometimes you have to almost manually reset a state. Imagine the situation, that you need to open another scene from your child module. You have something like sceneForOpen
property in the child module. The main module should send something, like sceneWasShown
, back to the child module for clearing sceneForOpen
property. Otherwise, if you go back and want to open the same page again nothing would happen, because the child's state hasn't been changed.
What about Side Effects?
Side effects realization with modularization highly depend on the way you implement these side effects.
With the second fully separated approach there's no difference in handling side effects at all. You have a fully separated module with its own side effects, when you create this module side effects will work, as I've shown in the previous article.
On the other hand, we have an approach with composable reducers. And thus I don't want to repeat the pointfree.co Effects
approach to modularization. You can easily find an explanation in their videos or on github.
However, I want to show you at least something. Composable architecture has its effects system, but imagine the situation when you have composable reducers as a modularization approach and query-based side effects. Here's some code, showing how you could create a child store (child module) from the main store (main module). The main idea here is that every module has its own store, but every child module has a piece of this store from the main module.
public func scope<LocalState, LocalEvent: ComponentableEvent>(
state toLocalState: @escaping (State) -> LocalState,
event toGlobalEvent: @escaping (LocalEvent) -> Event,
feedBacks: [SideEffect<LocalState, LocalEvent>]
) -> Store<LocalState, LocalEvent> where LocalEvent.State == LocalState {
let localStore = Store<LocalState, LocalEvent>(
initial: toLocalState(state),
feedBacks: feedBacks,
reducer: { localState, localEvent in
var state = self.state
self.reducer(&state, toGlobalEvent(localEvent))
localState = toLocalState(state)
if localEvent != LocalEvent.updateFromOutside(localState) {
self.accept(toGlobalEvent(localEvent))
}
}
)
$state
.map(toLocalState)
.removeDuplicates()
.sink { [weak localStore] newValue in
localStore?.accept(.updateFromOutside(newValue))
}
.store(in: &localStore.disposeBag)
return localStore
}
In this case, we have a scope
function which transforms the main store to the child one. Here SideEffect<LocalState, LocalEvent>
is a pairing of query and action. The main idea of this approach is that while we create a child module, we inject side effects from it to the scope
function. It gives us a way to construct modules with independent side effects. Every other action which is occurring in this function is mostly about main - child communication. It's necessary because we want to reflect changes from the main module inside the child one and visa versa.
There’s nothing hard to understand if you've already got how to handle modularization in general, trust me.
Outro
As usual, there's no silver bullet for modularization. You can decide for yourself what to use. So far, you've become familiar with how to work and even how to build your own reactive framework and unidirectional architecture. Actually you know most of these things already. However, in my opinion, there's still one important topic left - testing. Unidirectional
architectures give us a super elegant way of testing your code. How to do the testing I will show you in the next article. So, let's keep in touch!
If you liked this article don't forget to crash a clap button. Moreover, you can do it 50 times, for you, it's super easy, but it will be really helpful for me.
If you don't want to lose any new articles subscribe
to my twitter account
Twitter(.atimca)
.subscribe(onNext: { newArcticle in
you.read(newArticle)
})
Top comments (0)