-
Notifications
You must be signed in to change notification settings - Fork 3k
Redux
We're using Redux to solve race conditions in our project. View controllers are often handling too much logic, and MVVM pattern used in some places in our application isn't solving race conditions issues. We have our own Redux library implemented under BrowserKit
, and it's currently undergoing (as of 2024) to use Redux in new and older parts of our codebase, introducing that pattern one bit at a time.
The base of Redux architecture is that information always only flows in one direction. We don’t have communication between individual view controllers or individual delegator callback blocks. Information flow is structured and set in one very specific way. It is important that actions are dispatched on a single thread and that new states are processed sequentially. There should be only one global thread-safe instance of a store.
It is a declarative way of describing a state change. Actions don’t contain any code, they are consumed by the store and forwarded to reducers. Reducers will handle the actions by implementing a different state change for each action. Actions Are used to express intended state changes and don’t contain functions. Instead, they provide information about the intended state change. For example, the user to be deleted in a DeleteUser
action.
Middleares are responsible for producing state side effects or use dependencies. A good example of middleware are API calls, access storage, or log events to Firebase. Every time an action is dispatched it should go through all middlewares together with a state. Based on that the middleware can (but doesn’t have to) dispatch a new action(s) asynchronously.
Provide pure functions, that based on the current action and the current app state, create a new app state. Reducers are the only place in which you should modify the application state. Reducers take the current application state and an action then return the new transformed application state. The best practice is to provide many small reducers that each handle a subset of your application state.
State is a data structure (should always be a struct). You have only one data structure that defines the entire application state including the UI state and any model state you use in your app.
Stores your entire app state in the form of a single data structure. This state can only be modified by dispatching Actions to the store. The store then searches through the reducers looking for reducers that can handle the current action, after a new state is produced by the reducer the store sends the same action through the Middlewares looking for those who can handle that action. Whenever the state of the store changes, the store will notify all observers.
Store subscribers are types that are interested in receiving state updates from a store. Whenever the store updates its state it will notify all subscribers by calling the newState
function on each. Ideally, most subscribers should only be interested in a tiny portion of the overall app state. It’s important to allow the possibility of subscribing only to the portion of the app state as important for the subscriber.
The implementation is located in BrowserKit/Sources/Redux.
The architecture was first integrated into ThemeSettings and serves as an example of how we plan to use it in the project.
The app state is located at GlobalState
AppState contains an array of ActiveScreenState
and the reducer handles two actions: showScreen
which adds the screen passed into the array and closeScreen
which removes it.
The implementation of ActiveScreenState's reducer loops through the active screens array with the current state and action and the reducer of the active screens that can handle that action will return a new state.
For every new screen that integrates Redux we need to add a case to AppScreenState
and AppScreen
enums and implement the related case in each reducer.
App global Store initialized with the global AppState, AppState's reducer and array of Middlewares
A struct Should always represent a model of the state needed by the view to represent the UI. It has the following requirements to implement
- Needs to conform to
ScreenState
andEquatable
. - Need to provide an initializer that builds the state from AppState like:
init(_ appState: AppState)
- Includes the reducer implementation for the state like:
static let reducer: Reducer<Self> = { state, action in }
all the actions handled by the reducer should be added using switch-case and it will return a new state.
For more details check ThemeSettingsState
Each ViewState should have associated actions that will update the state. By convention, we will have two types of actions those who respond to user actions and those who are triggered by the middleware the name use should reflect what type of action is, for example if the user toggle a switch to change the usage of system theme, the user actions could be named toggleUseSystemAppearance(Bool)
and the middleware action could be systemThemeChanged(Bool)
For more details check ThemeSettingsAction
Not every Redux integration needs a Middleware but as explained above Middleware is where side effects happens or the changes to any external dependency happens. In this case we are using the ThemeManager to update the Theme Settings
For more details check ThemeMiddleware
To get store updates the observer needs to conform to StoreSubscriber protocol the following requirements are needed in order to getting the store new state:
- Conform to
StoreSubscriber
- Define
SubscriberStateType
typealias - Call to
store.subscribe
ideally passing only the Substate that the observer is interested in receiving updates, this Substate needs to match theSubscriberStateType
defined in the step above and the stateType for the newState func. - Implement
func newState(state: SubscriberStateType)
- Call to the store dispatch action to show the screen type like:
store.dispatch(ActiveScreensStateAction.showScreen(.themeSettings))
For more details check ThemeSettingsController
In order to support multiple iPad windows, our Redux patterns are being updated to accommodate multiple instances of the same screen type simultaneously. Because Redux processes all screen states for all actions, some care is needed when running in multi-window mode to ensure that an action in one window does not (unless you want it to) affect the state of other windows.
(Note: some aspects of this are in flux while development for multi-window is ongoing.)
Key takeaways, summarized:
- Our Redux
Action
protocol now requires that actions always have an associated window UUID - Similarly,
ScreenState
s should typically include a window UUID along with their other state properties - Actions should, with a few exceptions, always include either an
ActionContext
object or one of its concrete subclasses as their associated value. (For an example, seeTabPanelAction.swift
) - Reducers and Middlewares should compare the UUID of the Action and the incoming ScreenState before modifying the state, since an action in one window should usually only affect the screen state for its window. This can be custom-handled as needed, in areas where we want to update global state etc. But ultimately it is the responsibility of reducers and middlewares to be sure they are processing actions correctly and taking the window UUIDs into consideration when needed.
In Redux architecture, middlewares is the only type of object allowed to perform side-effects, so it's the only place where the testability can be challenging. To improve testability, the middleware should use as few external dependencies as possible. If it starts to use too many, consider splitting into smaller middleware, this will also protect you against race conditions and other problems, will help with tests and make the middleware more reusable. Also, all external dependencies should be injected in the initializer, so during the tests you can replace them with mocks.