This week we’ll talk about creating a state container similar to the one used by Redux . It is the only source of value for the application under development. A single state for the entire application makes debugging and verification easier. A single source of truth values eliminates thousands of errors that occur when creating multiple states in an application.
A single source of truth values
The main idea is to describe the state of the entire application through a single structure or composition of structures. Suppose we are working on a Github repository search application, where state is an array of repositories that we select according to a specific request using the Github API.
struct AppState { var searchResult: [Repo] = [] }
The next step is to pass the state (read-only) to each view within the application. The best way to accomplish this is to use SwiftUI's Environment. You can also pass an object containing the state of the entire application to the Environment of the base view. The base view will share the Environment with all child views. To learn more about SwiftUI's Environment, check out the Power of the Environment in SwiftUI publication .
final class Store: ObservableObject { @Published private(set) var state: AppState }
In the above example, we created a store object, which stores the state of the application and provides read-only access to it. The State property uses the wrapper of the @Published property, which notifies SwiftUI of any changes. It allows you to constantly update the entire application, deriving it from a single source of truth values. Earlier, we talked about storage objects in previous articles. To learn more about this, you should read the article “ Modeling Application State Using Store Objects in SwiftUI ”.
Reducer and Actions
It's time to talk about user actions (actions) that lead to state changes. An action is a simple enumeration or collection of enumerations describing a state change. For example, set the load value during data sampling, assign the resulting repositories to a state property, etc. Now consider the sample code for an enumeration of Action.
enum AppAction { case search(query: String) case setSearchResult(repos: [Repo]) }
Reducer is a function that takes the current state, applies an action to the state, and generates a new state. Usually reducer or composition of reducers is the only place in an application in which state changes. The fact that a single function can change the entire state of an application makes the code very simple, easy to test, and easy to debug. The following is an example of a reduce function.
struct Reducer<State, Action> { let reduce: (inout State, Action) -> Void } let appReducer: Reducer<AppState, AppAction> = Reducer { state, action in switch action { case let .setSearchResults(repos): state.searchResult = repos } }
Unidirectional flow
Now it's time to talk about data flow. Each view has read-only access to state through the store object. Views can send actions to the repository object. Reducer changes state, and then SwiftUI notifies all views of state changes. SwiftUI has a super-efficient comparison algorithm, therefore, displaying the state of the entire application and updating changed views is very fast.
State -> View -> Action -> State -> View
This architecture only works around a unidirectional data stream. This means that all the data in the application follows the same pattern, which makes the logic of the created application more predictable and easier to understand. Let's change the store object to support submitting actions.
final class Store<State, Action>: ObservableObject { @Published private(set) var state: State private let appReducer: Reducer<State, Action> init(initialState: State, appReducer: @escaping Reducer<State, Action>) { self.state = initialState self.appReducer = appReducer } func send(_ action: Action) { appReducer.reduce(&state, action) } }
Side effects
We have already implemented a unidirectional stream that accepts user actions and changes state, but what about an async action, which we usually call side effects . How to add asynchronous task support for the storage type used? I think it's time to introduce the use of the Combine Framework , which is ideal for handling asynchronous tasks.
import Foundation import Combine protocol Effect { associatedtype Action func mapToAction() -> AnyPublisher<Action, Never> } enum SideEffect: Effect { case search(query: String) func mapToAction() -> AnyPublisher<Action, Never> { switch self { case let .search(query): return dependencies.githubService .searchPublisher(matching: query) .replaceError(with: []) .map { AppAction.setSearchResults(repos: $0) } .eraseToAnyPublisher() } } }
We added support for async tasks by introducing the Effect protocol. Effect is an Actions sequence that can be published using the Publisher type from the Combine framework’s . This allows you to process asynchronous jobs with Combine, and then publish actions that reducer will use to apply actions to the current state.
final class Store<State, Action>: ObservableObject { @Published private(set) var state: State private let appReducer: Reducer<State, Action> private var cancellables: Set<AnyCancellable> = [] init(initialState: State, appReducer: Reducer<State, Action>) { self.state = initialState self.appReducer = appReducer } func send(_ action: Action) { appReducer.reduce(&state, action) } func send<E: Effect>(_ effect: E) where E.Action == Action { effect .mapToAction() .receive(on: DispatchQueue.main) .sink(receiveValue: send) .store(in: &cancellables) } }
Practical example
Finally, we can complete the repository search application, which asynchronously calls the Github API and selects the repositories that match the request. The full source code of the application is available on Github .
struct SearchContainerView: View { @EnvironmentObject var store: Store<AppState, AppAction> @State private var query: String = "Swift" var body: some View { SearchView( query: $query, repos: store.state.searchResult, onCommit: fetch ).onAppear(perform: fetch) } private func fetch() { store.send(SideEffect.search(query: query)) } } struct SearchView : View { @Binding var query: String let repos: [Repo] let onCommit: () -> Void var body: some View { NavigationView { List { TextField("Type something", text: $query, onCommit: onCommit) if repos.isEmpty { Text("Loading...") } else { ForEach(repos) { repo in RepoRow(repo: repo) } } }.navigationBarTitle(Text("Search")) } } }
We divide the screen into two views: Container View and Rendering View . The Container View manages the actions and selects the necessary parts from the global state . Rendering View receives data and displays them. We already talked about Container Views in previous articles, to learn more, follow the link “ Introducing Container views in SwiftUI ”
conclusions
Today we learned how to create a Redux-like state container with side-effects in mind . To do this, we used the SwiftUI Environment function and the Combine framework. I hope this article has been helpful.
Thanks for reading, and see you soon!