24
loading...
This website collects cookies to deliver better user experience
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.RxSwift
or Combine
, or reactive programming in general, I’d suggest reading my first article as well.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.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
}
}
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
}
}
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:App
needs to know about all changes in the News
News
needs to know about all changes in the part of the App
(User
)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)
}
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.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
}
}
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
}
}
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
}
}
compose
two separate reducers
into one. So, for the first step, we need a reducer declaration.typealias Reducer = (AppState, AppEvent) -> AppState
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
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)
}
}
}
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
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
AppReducer
this:func reduce(state: AppState, event: AppEvent) -> AppState
func reduce(state: NewsState, event: AppEvent) -> NewsState
func reduce(state: AppState, event: AppEvent) -> AppState
func transform(newsReducer: (NewsState, AppEvent) -> NewsState) -> Reducer
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
???
}
}
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)
}
}
Cannot convert return expression of type 'NewsState' to return type 'AppState'
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
}
}
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)
}
}
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
}
}
transform
function will look like this:func transform(newsReducer: (NewsState, NewsEvent) -> NewsState) -> Reducer
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
}
}
Cannot convert value of type 'AppEvent' to expected argument type 'NewsEvent'
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
}
}
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
}
}
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
}
}
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
}
}
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
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.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.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.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)
}
}
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.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)
}
}
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.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.Effects
approach to modularization. You can easily find an explanation in their videos or on github.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
}
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.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!Twitter(.atimca)
.subscribe(onNext: { newArcticle in
you.read(newArticle)
})