React Store and Reducers
Writing a react and redux appliction is a great experience, we all have seen the vision of great applications. However every time you sit down to work on your application there are times that the boilerplate is killing you. In most Redux applications you start seeing it very quickly, since there are great initial demos of "Todo" or other classic applications.
The question is how to create great patterns that scale, rather than great demos that give a flavor.
Backstory
I have been writing software for many years and playing on my own time with React and Redux. I walked into a project that had a medium sized application already written in React/Redux. Usually that would be a great thing, since you can take the at-home experiences that you have and learn from others. In this case it was an application built by contractors who hadn't ever built a React/Redux application before. It's still a great chance to learn, since different people have different tricks, but it also presents some interesting challenges.
What did I find
What was the ugly? You know there was quite a few things, here's a short list of what I needed to do and clean up.
-
Establish eslint standards - Used the AirBnB set, it's solid and functional. The code style with about 6 different people working on it was all over the board and made it hard to quickly understand what was happening when you opened a file. Lets just say when it first ran it pulled over 12,000 errors (hint: eslint --fix is your friend to reduce it to 4,000).
-
Cut-n-paste components - Well over half of the components were copies of other components with imports that weren't used, methods that never were accessed and other cruft.
-
Create actions for Redux - The system only had two actions "load" and "notify" the
loadStore()
action looked like a huge switch statement of these. There was a fleet of reducers on the other side, but every other request that wasn't fetch blob of data was an XHR style request in the React components.1case "team": 2 return (dispatch, getState) => { 3 dispatch(requestTeam()); 4 request.send( 5 '/v1/team-members', 6 'GET', 7 (err, res, param) => { 8 dispatch({ 9 type: 'RECEIVE_TEAM', 10 team: res.body, 11 }); 12 }, 13 null, 14 options, 15 );
-
No prop validation - Yeah, you're expecting that after having no linting standards. Adding those takes time, but it makes the documentation of components self evident.
-
External form models - There was a really cool idea of having all of the models specified by JSON files that would be fetched from the server to generate forms. It worked and was interesting, the challenge was that it ment every form render had to do a server round trip just to fetch the data. While these could have been imported, it's easier just to build out a
Form
component design pattern. This global form component only knew how to XHR back to the server to GET/POST/PUT payloads. This is tying the FORM too closely to the API and the API team had to design for the UI (custom endpoints). -
The love of state - Most components in the system used the following pattern, rather than just leaving things in props. Yes, it was much worse than this in many cases.
1componentWillReceiveProps(nextProps) { 2 this.setState({ 3 data: nextProps.data, 4 }) 5}
-
Constants in state - Not only is the state loved, but it so loved it has everything you need. Can we say clutter.
1this.state = { 2 data: { 3 total: {}, 4 GatewayTotal: {}, 5 }, 6 columnLabels: [ 7 { label: "Method", value: "method" }, 8 { label: "# Payments", value: "count" }, 9 { label: "Value", columnClass: "u-right-align", value: "value", func: "currency" }, 10 ], 11};
Structuring Redux Stores
The clear thing to do was estiblish read redux actions that supported the API endpoints and moved the React application back to view oriented rendering. The challenge is how to build a great redux design for an existing appliction, since large application redux designing isn't a hotly dicussed topic this is what I did.
Some observations:
- There are three primary data pools in an application.
- Individual objects - The objects themselves.
- List of objects - List results (searches, etc.)
- Auth/Session/Notifications - Application state
- Caching is important.
- Boilerplate is bad... We've all written the same reducer with the names changed, over and over again.
Basic store design
I recently read this blog post about twitters redux store. It did present a few interesting ideas, which I adopted. The design of your store is more important than you think, since it's how you're going to structure the application.
1fetchStatus: ENUM{
2 undefined,
3 'loading'
4 'loaded'
5 'error'
6}
7
8storeData: {
9 entities: { ID: Object },
10 fetchStatus: { ID: fetchStatus },
11 errors: { ID: Object },
12}
Caching Redux Data V1
When you Google around for this question, you'll find lots of articles that avoid talking about it. Since your store should be stateless. You're going to need to cache data to have an efficent application and the store is a good representation of the state of your application which you will be communicating out to your user. Instead of not talking about it, lets do it...
The start of this loadRecipient()
we check to see the state of the store.
1export function loadRecipient(id, force) {
2 const { entities } = store.getState();
3 const data = entities.recipient;
4
5 return (dispatch) => {
6 if (!force && (data.fetchStatus[id] === 'loaded' || data.fetchStatus[id] === 'loading')) {
7 return;
8 }
9
10 if (data.fetchStatus[id] !== 'loaded') {
11 dispatch({
12 type: RECIPIENT_LOADING,
13 loading: "loading",
14 id,
15 });
16 }
17
18 request.GET(`/v1/recipients/${id}`).then(([res, err]) => {
The key things to see here are:
- We return a no-op action in the event that we've already fetched the data. Since your pattern in the Component is
dispatch(loadRecipient(id))
, since you always dispatch you have to return a valid actionundefined
will just crash your application. - We set the
loading
state to allow both future requests and the UI to indicate data is coming. - We of use a thunk middleware, so we can do an async fetch and get a result back later.
Basic Reducer V1
Reducers, this is the number one source of boilerplate in most Redux applications. For the moment the application is saving all of the data in the "store.entities" component. The big thing is that while we use "tokens" for our actions and the state, we're also taking advantage that we know that they're really strings and taking advantage of well structured strings to perform the operations that we need.
1export default function entities(state = initialState, action) {
2 if (!action.type.startsWith("entities/")) {
3 return state;
4 }
5
6 const parts = action.type.split("/");
7
8 if (parts.length !== 3) {
9 return state;
10 }
11
12 const type = parts[1];
13 const opcode = parts[2];
14 const id = action.id || "data";
15
16 if (opcode === "LOADING") {
17 return state.setIn([type, "fetchStatus", id], action.loading || "loading");
18 } else if (opcode === "ERROR") {
19 return state.setIn([type, "fetchStatus", id], "error").setIn([type, "errors", id], action.error);
20 } else if (opcode === "DATA") {
21 return state.setIn([type, "fetchStatus", id], "loaded").setIn([type, "entities", id], action.data);
22 }
23
24 return state;
25}
Always be improving
We we have a basic pattern for our actions and a basic pattern for reducers. The problem is that way too much is now stored in level deeper than we really wish. How can we improve it but not create more boilerplate.
JavaScript is also a great templating language. We can quickly whip up this reducer generator.
1import Immutable from "seamless-immutable";
2
3/*
4 * Standard reducer definition
5 *
6 * It assumes that all actions are of type NAME/OPCODE
7 *
8 * where name is the name that you registered when you added this to the reducer pipeline
9 *
10 * e.g.
11 * ...
12 * payments: standardReducer('payments')
13 * ...
14 *
15 * It assumes all action.types will have the form 'name/OPCODE' where OPCODE is one of:
16 *
17 * LOADING -- Mark the fetchStatus as 'loading'
18 * ERROR -- Mark fetch status as 'error' and save the code in error[id]
19 * DATA -- Save the object in the data table and set fetchStatus = 'loaded'
20 */
21export default function standardReducer(name, extra) {
22 const initialState = Immutable({
23 entities: {},
24 errors: {},
25 fetchStatus: {},
26 });
27
28 return (state = initialState, action) => {
29 if (!action.type.startsWith(`${name}/`)) {
30 return state;
31 }
32
33 const parts = action.type.split("/");
34
35 if (parts.length === 1) {
36 return state;
37 }
38
39 const opcode = parts[parts.length - 1];
40 const id = action.id || "data";
41
42 if (opcode === "LOADING") {
43 return state.setIn(["fetchStatus", id], action.loading || "loading");
44 } else if (opcode === "ERROR") {
45 return state.setIn(["fetchStatus", id], "error").setIn(["errors", id], action.error);
46 } else if (opcode === "DATA") {
47 return state.setIn(["fetchStatus", id], "loaded").setIn(["entities", id], action.data);
48 } else if (extra) {
49 return extra(state, action);
50 }
51
52 return state;
53 };
54}
The good, we now can add reducers to our system like this, we can even quickly extend a basic reducer with additional action handlers.
1const rootReducer = combineReducers({
2 activity: standardReducer("activity"),
3 batchSummary: standardReducer("batchSummary"),
4 recipientUpload: standardReducer("recipientUpload", recipientUploadMethods),
5});
If we need to add some extra reducer functions beyond a basic load function it's easy:
1import { RECIPIENT_UPLOAD_PROCESSING_START } from "../actions/recipientUpload";
2
3export default function batchUploadMethods(state, action) {
4 if (action.type === RECIPIENT_UPLOAD_PROCESSING_START) {
5 return state.setIn(["entities", action.id, "status"], "processing");
6 }
7 return state;
8}