Skip to content
mattreaganmozilla edited this page Mar 22, 2024 · 10 revisions

Overview

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.

Architecture

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.

Redux

Components

Actions

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.

Middlewares

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.

Reducers

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

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.

Store

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

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.

App integration

Redux implementation

The implementation is located in BrowserKit/Sources/Redux.

Usage in project

The architecture was first integrated into ThemeSettings and serves as an example of how we plan to use it in the project.

AppState

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.

AppState

Store

App global Store initialized with the global AppState, AppState's reducer and array of Middlewares

ViewState

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 and Equatable.
  • 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

Adding and dispatching actions to the store

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

Handling side effects through Middleware

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

Getting store updates with new state

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 the SubscriberStateType 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

Redux and Multiple iPad Windows

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, ScreenStates 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, see TabPanelAction.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.

Testing

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.

Clone this wiki locally