Organizing Large React Apps
An opinionated tech stack and structure
When I first started writing React apps, I spent weeks, maybe even months, wrapping my head around all the different frameworks for state management. I looked at Redux, Thunk, Saga, etc. Hell even before React, I spent time looking at other UI frameworks like Vue. When I settled on React, it quickly became apparent that the Hello World equivalent for UI tutorials (the ToDo list) was well intentioned but could not possibly prepare a newbie with enough knowledge to organize an actual React app with even mild complexity. So based on what I learned along the way, and my experience of wasting time with many other approaches, I finally came up with scaffolding that I think works really well to keep state management in React clean and well organized. This article assumes you are already familiar with Redux and React concepts and code. That being said, here is what works for me…
Redux Review
When dealing with Redux, you generally have “actions” and “reducers”. Actions essentially label activity with some parameters while reducers manipulate the actual state based on those actions. Redux just wires up the plumbing to fire the reducers when actions are “dispatched”.
In Redux, you will see something like this:
const mapStateToProperties = state => ({
someState: state.somePath.someStateVariable
});const mapDispatchToProperties = dispatch => ({
doSomething: () => {
dispatch(myCustomActionGenerator());
}
});export default connect(mapStateToProperties, mapDispatchToProperties)(MyComponent)
This snippet is practically boilerplate for every React app written. You have a function that exposes specific state attributes to a Component, another function that enables dispatching actions to Redux, and a connector that brings them all together in the “props” attribute of a Component. But notice one very important thing here: the dispatched functions do NOT have access to the state tree! So what happens if you need to blend attributes from the Redux state tree in order to complete a task? You would have to pass in all state information to the “doSomething” function and hope for the best. This gets messy really fast since 1) your mapStateToProperties function would have to pass a lot more state to the component and 2) that means it would be refreshing a lot more often because it’s coupled to elements in the state tree that it doesn’t even care about! There has to be a better way …and there is.
Thunk
My first question when I saw Thunk was, “what the hell is a thunk? Is it some old-world rendition of ‘think’?” In computer science, it’s actually, “a subroutine used to inject an additional calculation into another subroutine.” Ah, that makes more sense. Thunk is injecting functionality that enhances the Redux experience. More specifically, it exposes both dispatch and state to your action generator functions.
In the scenario above, we called out to a “myCustomActionGenerator” function and then dispatched the result of that function. Let’s look at the vanilla Redux and Thunk implementations.
Vanilla Redux:
function myCustomActionGenerator() {
return {
type: DO_SOMETHING,
custom: {??}
}
}
Thunk:
function myCustomActionGenerator() {
return async (dispatch, getState) => {
let state = getState();
let res = await someLongAsyncAction(
state.somePath.someAttribute);
dispatch({
type: DO_SOMETHING,
custom: { result: res }
});
}
}
Whoa! What just happened? In the vanilla version, we could only return a type with some parameters. But how does the custom object in the return value get filled in with attributes? Any attributes would have to be passed in to the function somehow. But in the Thunk version, Thunk injected the “dispatch” and “getState” functions so that we can 1) get any state variables we need and 2) dispatch a final result back to Redux!
So what’s going on here? Thunk is middleware — it listens to every Redux dispatch call and when it sees that the dispatch argument is a function, it calls that function while supplying the “dispatch” and “getState” functions as arguments. Redux doesn’t trigger the reducer because Thunk intercepted the call and instead invoked the function you provided. In the follow-on dispatch call inside myCustomActionGenerator, once again Thunk sees the call but notices that the argument is NOT a function. So it passes it back down to Redux which fires reducers as normal. Here is what the entire flow looks like:
Pretty cool right? It is; yet I found myself struggling to figure out how the hell to keep all this organized. Where do the various “myCustomActionGenerator” functions get defined? How can I structure the project in a way that makes it easy to understand what’s happening where? How do I avoid a bunch of callback hell scenarios with action code littered here and there throughout my code? What if a single button click needed to update several parts of the state tree or UI? How could I ensure decoupling/abstraction between Components and dispatched actions? The answers came when I found Reduxsauce.
Reduxsauce
In a nutshell, Reduxsauce makes building Redux apps more intuitive (at least to me). Basically, it allows me to think about Components and state in terms of their API — what actions can I invoke, and how do those actions impact the state tree? It allows me to more easily separate concerns within a large React app. How does it do this? Let’s see an example by refactoring what we’ve discussed so far using Reduxsauce.
action.js
import {createActions} from ‘reduxsauce’;const {Types, Creators} = createActions({
doSomething: [‘custom’],
anotherUpdate: [‘param1’, ‘param2’],
},{prefix: “myAction.”});
export { Types, Creators}
Wait, this is all completely new code, what am I doing to you? Let me explain. In Redux, you can have functions that generate the {type:…} structure that Redux needs to fire appropriate reducers, right? But each time I build one of those functions, I have to remember a lot about that function including the specific “type” label, arguments, and where the thing is defined. What Reduxsauce offers is a utility to generate those functions for me…with a styling convention that makes it easier for me to think about the state change interactions. Namely, I can simply dispatch to any of my “Creator” functions and it will produce the appropriate action with types, parameters, etc.
So all this code is saying is, “there are a bunch of labeled actions with named parameters, all having a prefix of “myAction.”. It will take care of generating all the boilerplate code needed to produce the {type…} structure that Redux needs. We’ll get to how that’s done in a minute. First, let’s stay inline with Redux and go to the reducers.
reducers.js
import {createReducer} from ‘reduxsauce’;
import {Types} from ‘./actions’;const INIT = {
stateVar1: “someDefaultValue”,
stateVar2: “anotherDefaultValue”
}const didSomething = (state=INIT, action) => {
return {
…state,
stateVar1: action.custom.var1,
stateVar2: action.custom.var2
}
}const HANDLERS = {
[Types.DO_SOMETHING]: didSomething
}
export default createReducer(INIT, HANDLERS);
This isn’t too far off from normal reducers except for some wiring that associates the types to reducer functions. All reducers need an initial state. That’s defined in the INIT object. In the example, the INIT is used as the default value for “state” in the reducer function, “didSomething”. In addition to state, we’re given the action that triggered the reducer. But what actually calls this reduce function? Well, instead of having this big switch statement keying off the action.type variable, Reduxsauce uses its own “createReducer” function to basically take care of the boilerplate switch logic to map an action type to a reducer function. The reducer just does the normal state updates using the parameters in the action. Note that in this case, it’s using “custom.var1, etc”. Where did “custom” get defined? Back in action.js — remember where we named the expected parameters for the “doSomething” action? This is where that definition gets used!
But wait, how did “doSomething” become “Types.DO_SOMETHING”? This is the Reduxsauce convention. It basically turns any defined action, like “doSomething” into upper-snake-cased names. Since doSomething has an uppercase ‘S’, it inserts a “_” and turns it all to uppercase. If it were “doSomeThing”, it would become “DO_SOME_THING”, etc. It’s just making sure you always have a consistent type definition vs. hand-jamming strings all over the place.
Ok, so how do these two things get triggered? You could literally do something like this:
import {Creators} from ‘Redux/feature1/actions’
const mapDispatchToProperties = dispatch => {
dispatch(Creators.doSomething({var1: ‘something’, var2: ‘somethingElse’}));
}
But we already know that it won’t be very useful because we’re hardcoding the parameters for the doSomething action. Instead, we want dispatch to a Thunk-aware “operation”.
operations.js
import {Creators} from ‘./actions’;const doSomething = () => async (dispatch, getState) => {
let state = getState();
let res = await someLongAsyncAction(state.somePath.someAttribute);
dispatch(Creators.doSomething({
var1: res.variable1,
var2: res.variable2
});
}export default {
doSomething
}
Aha! This is interesting. So we define a set of Thunk-aware “operations” that can access the named actions through the Creators object. Note that in this case, Creators just uses the function name as we defined it in actions, not it’s translated Types. This will leverage all of the Reduxsauce boilerplate logic to convert type names, variable names, etc.
Great, but how does this make it easier to think about Components and their API? Here is the big picture that I usually see when building React apps:
In this diagram, you have UI components whose state is derived from Redux/Thunk updates. The Redux/Thunk updates are driven by operations and state-changing actions/reducers. The “operations” become the API for each major feature of an app and are generally what are invoked when a user interacts with the site.
Thinking of it like this also helps organize the code — since the actions, reducers, and operations are all associated with a feature/module of a larger app, I can organize them all in the same place, in a directory named by its feature/module. The layout might look like this:
myProject/
src/
Redux/
feature1/
actions.js
operations.js
reducers.js
feature2/
actions.js
operations.js
reducers.js
Components/
…
Then your Redux connector would look like this:
import {default as featureOps} from ‘Redux/feature1/operations’;const mapDispatchToProperties = dispatch => ({
doSomething: () => dispatch(featureOps.doSomething())
})
Now, obviously you can define non-Reduxsauce operations that dispatch calls to action generators, etc. But the boilerplate would get exhausting and the switch statements would be tedious as hell. But following the Reduxsauce convention, things seem more like isolated, single-purpose units that I can interact with through their operations. Whatever updates happen as a result of the operation calls are isolated to that individual module/feature. There is so much more you can do with this and maybe I’ll give examples in a future article.
But Wait…
I’m glossing over something that some of you may be scratching your head about…how do those “Redux/feature1/operations” imports actually get resolved? Why am I not using “../../../../” all over the place? Well, the secret sauce is the “cross-env” utility. This very useful library allows me to set additional root paths for resolving imports. When I start my react app, I use this in my package.json:
package.json
…“start”: “cross-env NODE_PATH=src SASS_PATH=node_modules:src/scss npm run start-js”
This basically says, “when resolving imports, check the ‘src’ and ‘src/scss’ dirs as well”. That allows me to import starting at “Redux” instead of “../../../../Redux” or whatever. It totally makes refactoring code a breeze.
It does, however, mean that most directories under src will be named using upper case. This is because if you tried to import “redux/feature1/operations”, it would confuse that with the actual redux library! So using Redux makes sure we’re referring to the right thing.