-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Redux Guidelines FAQs
This page involves some tidbits and guidelines when implementing or working with Redux in our application. If you are looking where to get started, please visit our other Redux wiki pages first:
Now that you have the gist of it from the other wikis, proceed!
We are adopting Redux on the team to utilize the benefits of structuring information in a single directional flow and maintaining state. To help establish best practices and maintain readable code, here are some guidelines.
- The state provides the data for a view. The reducer should not contain complex logic and simply should return the state for the view.
- A new state is returned by dispatching an action to the store, which is calculated by the reducer. We should always be returning a new state in the reducer, we should not have any
return state
and instead return a new state configured with the proper default values.
DO:
guard action.windowUUID == .unavailable || action.windowUUID == state.windowUUID else { return ExampleState() }
AVOID:
guard action.windowUUID == .unavailable || action.windowUUID == state.windowUUID else { return state }
- New actions should be created as classes and in it's own separate class file. For each action, there are associated action types.
- Payload for actions are created as properties on the action classes. They should be properties and not an associated value on the enum case.
DO:
final class ExampleAction: Action {
var buttonPayload: String?
init(
buttonPayload: String? = nil
windowUUID: WindowUUID,
actionType: any ActionType,
) {
self.exampleProperty = exampleProperty
super.init(windowUUID: windowUUID, actionType: actionType)
}
}
enum ExampleActionType: ActionType {
case tapOnButton
}
AVOID:
final class ExampleAction: Action {
override init(windowUUID: WindowUUID, actionType: any ActionType) {
super.init(windowUUID: windowUUID, actionType: actionType)
}
}
enum ExampleActionType: ActionType {
case tapOnButton(String)
}
- Middlewares are optional. The middleware serves as a way to communicate to the reducer and not everything needs it.
- Don’t have a middleware without an explicit reason. Some use cases for middlewares can be to handle side effects from actions (i.e. send telemetry) or include complex logic such as making network requests or reading data from storage.
- Dependencies in the Redux architecture flow should live here.
- A new action handled in the reducer may be sent as a byproduct of an action that gets sent to the middleware (i.e. getting data from storage / network request), but we do not have to always dispatch an action if the reducer does not need to communicate to the middleware.
See Reference PR: Remove unnecessary actions dispatches in middleware
Naming Convention
- When creating action types, lean towards using user and API action names over state changed action names. We want our actions to read clearly on what action was taken and not the consequence of the action. The view should not know the consequences.
- Rule of thumb: Actions should be named after the event that triggered them and not the desired outcome.
DO:
enum ExampleActionType: ActionType {
case tapOnButton
}
AVOID:
enum ExampleActionType: ActionType {
case updateView
}
View models should not exist
- Redux and MVVM are two different systems that don’t really work together.
- Business logic should be handled by the middleware. Presentation logic should be handled by the reducer by returning the proper state.
- There are some cases in which we want to take the model and add some logic so that it's presentable for the view. Although this is essentially a view model, we prefer to name these classes as states instead.
Switch Statements
- The reducer and the middleware always switch over action types.
- There is a 2 lines maximum rule for the switch statement case so that they can maintain readability.
No need to explicitly call main thread
- The store will automatically ensure actions are executed on the main thread so we don’t need to add another check for main thread.
Call reducers explicitly
- We want to explicitly call to reducers to handle the update of the state instead of passing in the previous state.
DO:
case ExampleActionType.tapOnButton:
return ExampleState(
windowUUID: state.windowUUID,
exampleSubState: ExampleSubState.reducer(state.subState, action)
)
AVOID:
case ExampleActionType.tapOnButton:
return ExampleState(
windowUUID: state.windowUUID,
exampleSubState: state.exampleSubState
)
- At a minimum, state should have tests for each action that it handles directly.
- Middlewares should also be tested. Refer to unit test guidelines in testing with the store.
How do I know which action to dispatch and where? Do I always need to dispatch an action from the middleware? Redux helps us simplify how we think by allowing us to not be concern about the consequences of any action taken. A dispatched action can update state in multiple places. However, we should only fire an event if needed. Therefore, we don't need to always fire an event from the middleware unless we are waiting for a response in the middleware to update the state. For example, if we're capturing telemetry, then we only need to dispatch an action to the middleware and not to be handled in the reducer. Here is a place where we were dispatching unnecessary actions and removed it in the PR.
When it comes to naming convention, we dispatch general action names from the view and the middleware dispatches middleware actions.
i.e. GeneralActionType.show
lives in the view and GeneralMiddlewareActionType.show
lives in the middleware.
In the middleware that's fetching the information, I try to unwrap the optional info object? Which option should I choose?
-
Option 1: In the middleware that's fetching the information, unwrap the optional info object and: a) if it exists, then dispatch the appropriate action, or b) if it doesn't exist, log a warning and dispatch an error type action for my view to handle.
-
Option 2: Pass the object around through actions till the place it would need to be used, and try to unwrap it where I need it, and, if it's nil, log an error there, and try to gracefully recover by handling the data appropriately?
Choose Option 1.
What's the preferred pattern when a middleware starts to get too big?
Let's try to split the middleware up based on responsibilities.
Context: One example is our Tabs middleware is already at 1k lines, which means its too broad of a concept for one middleware to be responsible for.
Is there such thing as dispatching too many redux actions? Should views optimize for reloading content?
Don't optimize unless you run into performance issues. If we need to solve this problem, bring it up to the team and can discuss whether we want to add to our system a way to only dispatch newState
to observers where the state has changed.
newState
method is being triggered for state changes relating to actions outside of the ones I care about, so we’re getting a bunch of newState
calls with identical input states, which leads to repetitiveness (eg. screen keeps appearing, animation keeps triggering)
Check whether you are resetting the state properly in cases that are not associated with the specific action you care about. Ensure that in the reducer we should always clear transient data in the default states. Therefore, we should not be using return state
and instead declare a new state with the default values, then anytime the reducer fires for any other reason the state of the field will be reset.