-
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.
State is an immutable 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. The overall app state can contain smaller state structs which describe how a subset of the application should work (e.g. TabsPanelState
).
Actions are a declarative way of describing a state change. They don’t contain any business logic. They are simple models which provide information about the intended state change (for example, the user to be deleted in a DeleteUser
action).
Reducers provide pure functions which take in an action and the current app state and then return the new transformed app state. Reducers are the only place in which you should modify the app state. Best practice is to provide many small reducers that each handle a subset of your app's state.
Middlewares are responsible for producing state side effects or leveraging dependencies. Good examples of logic that belongs in a middleware are API calls, storage access, or logging events to Firebase. Every time an action is dispatched, it should go through all middlewares alongside a state. While processing an action, the middleware can (but doesn’t have to) asynchronously dispatch one or more new actions.
The store contains your entire app's state in the form of a single data structure. Whenever the state of the store changes, the store will notify all its subscribers. The app state can only be modified by dispatching actions to the store. The store then searches through the reducers for a reducer which can handle the current action. Once a new state is produced by the reducer, the store then sends the same action through the middlewares, looking for those which handle that action.
Store subscribers are objects that are interested in receiving state updates from the store. Whenever the store updates its state, it will notify all subscribers by calling the newState
function on each subscriber. Ideally, most subscribers should only be interested in a tiny portion of the overall app state. Therefore, it is important that a subscriber be allowed to subscribe to only a subset of the overall app state.
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)
- Needs to typically include a property for its associated window UUID (if the screen can be open on multiple iPad windows)
- 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(ScreenActionContext(screen: .themeSettings, windowUUID: uuid)))
Note that for the example .showScreen
action we supply a ScreenActionContext
, which provides both the screen that is being displayed as well as the associated iPad window UUID.
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 need to use care to only perform actions relevant for the windowUUID included by the dispatched action
- Reducers are processed once for every currently active screen state, which means at the beginning of most reducers we can simply check if the action UUID matches the state window UUID that is being reduced (in most cases, if it does not, we should ignore the action since it is not for the current window)
- Middlewares, by comparison, are only processed once per action (regardless of how many active screens/windows there might be). This means special care must be used by middlewares to take the action UUID into consideration and perform any related updates accordingly.
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.