Reading this, keep in mind this is the result of reading a lot of the Redux FAQ and some related blog posts from Abramov et al so theres a very good chance we'll need to tweak these guidelines going forward.
Additionally, there are some solutions that I'm not totally sold on, but we'll have to pick something and then iterate on it.
- Consistent structure to reduce the mental overhead of adding new actions, reducers and sagas
- Normalize all our "entity" data to make sure we are storing data on the front end in a consistent way
- Consistent way to handle errors that arise from making API requests and responses
- Split store up into "entities/data", "ui" and other domain specific data.
- Entities is purely concerned with storing the response from the backend
- UI is purely concerned with storing state related to the front end UI state. Stuff like "sideBarOpen".
- Domain data is basically what we're used to adding to the state. This includes storing things like "currentUser", "activeDecision", etc. which should just reference entities by ID
- Reducers that act on this data shouldn't use type but act on actions with the "data" key. We do not want to couple these reducers to a single action. That will only produce problems with API responses that include nested resources. If we normalize the responses that get pushed to our reducers this shouldn't be an issue.
- Actions that trigger API requests need both a success and failure sate
- Even if you don't handle the failure state gracefully we should get into the habit of writing this way
- Actions should NOT necessarily map one-to-one with reducers. Instead, we can model actions are events (or actions!) that occur as part of the user interacting with our UI and our frontend making API calls.
- We should avoid dispatching many actions instead of one. For example, when we want to login a user:
function loginReducer(state, action) {
switch(action.type) {
case LOGIN_SUCCESS:
return {
...state,
loggedIn: true
}
default:
return state;
}
}
// BAD
function userReducer(state, action) {
switch(action.type) {
case SET_USER:
return action.user;
default:
return state;
}
}
function* loginSaga () {
try {
const user = yield call(Api.App.post, 'login', action.user);
yield put(loginSuccess(user));
yield put(setUser(user));
} catch (error) {
yield put(loginFailure(error.body))
}
}
// GOOD
function currentUserReducer(state, action) {
switch(action.type) {
// Accept both actions
case LOGIN_SUCCESS:
case SET_USER:
return action.user;
default:
return state;
}
}
function* loginSaga () {
try {
const user = yield call(Api.App.post, 'login', action.user);
yield put(loginSuccess(user)); // Dispatch single action
} catch (error) {
yield put(loginFailure(error.body))
}
}
Actions are broadcast to ALL reducers, and we should take advantage of that fact, within reason.
The above example could be further improved by having a more specific action than SET_USER
. We should consider when event occurs: when a user logs in, signs up, or accepts their invitation using the email link. These could all have an action associated with them that our AuthReducer
and UserReducer
could handle appropriately.
Additionally, it might be worth storing our user in our entities
state and just referencing it with an id in activeUser
.
- Be careful with catching errors. There's a good change we'll catch errors that we don't actually want to catch. Instead, we should be using promise rejections to return objects with an error field that we conditionally pick up.
- This is because error catching in JS is too permissive, and will scoop up even legit errors that have nothing to do with our API response.
- We should have a single place in our state that we can store our errors, so we can have a single set of reducers for it.
-
Will a single place for errors make sense?
- Potential errors:
- 400 from create -- associated with data, not instance
- 400 returned from edit -- associated with data + instance
- 401 Authentication -- associated with specific request
- 403 Permission -- associated with specific request, but also resource.
- When we're creating a new instance, we can't put the errors in our
entities
because our instance doesn't have an entry there. - When we recieve 401 or 403s, they can be associated with any request, and not necessarily an instance or list of entities.
- Additionally, we need a way to clear the errors when appropriate. Sending up new data that returns a 200 should clear our validation errors state. When we login, we can clear 401s (not necessarily true for 403s).
- Potential errors:
-
Where should our
isFetching
/isLoading
state live? If we want to show the user that things are loading we'll need to be able to easily associate a part of the UI with a request...- I think for now we can put it on the entities field and not distiguish between loading a single vs. loading a bunch of entities. We'll have to re-evaluate this solution as we work with it.
{
activeDecision: "uuid",
activeUser: "uuid",
auth: {
loggedIn: true,
}
ui: {
sidebar: {
open: true
}
}
entities: {
decisions: {
byId: {
'uuid-1': {
...decisionData
},
},
allIds: ['uuid-1'],
isFetching: false
},
recommendations: {
byId: {},
allids: [],
isFetching: true
}
},
errors: {
api: {
'401': 'Unauthorized'
}
rating: {
'non_field_errors': 'some error returned from the API'
}
}
}