I have been following the widely given advice of learning React development by first mastering component props
, encapsulating UI state in component level this.state
and passing it down selectively through the component tree. It's been an enlightening experience. I've come to appreciate the power of the stateless view design pattern and I feel that I've been able to achieve robust and well organized results using these techniques.
Moving on, I am now trying to incorporate more sophisticated state management using redux
. But as I wade through the complexity and integrate redux
into my apps, I find myself confronting the following observations about how my code has evolved. Some of these developments seem sensible, but others make me question whether I'm doing things 'right'.
1) Action Creators as the nexus of business and UI logic
I find that much of the logic that was previously implemented in the React lifecycle functions componentDidUpdate
etc., and in onTouch/onPress
handlers, is now implemented in action creators. This seems to be a positive development as it keeps 'everything in the same place' and allows for unit testing.
Question: Is it best practice to concentrate business logic in a web of fairly intricate action creators?
2) Hollowed out reducers
As a corollary to #1 above, I find that my reducers
and their corresponding action
objects have evolved into a de-facto list of setters that do little more than update the state store with the passed along values, in this fashion:
case types.SAVE_ORDER:
return Object.assign({}, state, {
order: action.order,
});
A big part of the reason for this is that reducers
are supposed to be pure functions and therefore I'm limited in what I can do with them (e.g. no async processing). Additionally, reducers are allowed only to operate on their respective sub-section of the store state. Given that much of my app's complexity already necessarily resides in the action creators, I find it hard to justify arbitrarily migrating complexity into reducers
simply for the sake of making them 'look useful'.
Question: Is it normal, and acceptable practice to have boilerplate reducers
that function merely as glorified setters to the redux store state?
3) redux-thunk
everywhere
I've asked separately on SO why redux-thunk
is even necessary (as opposed to calling standard action creators inside of async callbacks/utility functions). I've been pointed to this answer by Dan Abramov which provides a very satisfactory explanation (vis-a-vis scalability, server side rendering and myraid other reasons).
Having accepted the necessity of redux-thunk
, I find that the majority of my action creators need to perform async actions, need access to getState
, or dispatch
multiple changes to the state. As a result I've been returning 'thunks
' extensively.
Question: Is it normal for a redux application to rely extensively on thunk
'ed action creators, and rarely to fire a standard object action directly?
4) Redux as global this.state
In the final analysis, it seems my app's redux
store has evolved to effectively resemble a global this.state
. You could think of it as keeping the entire application state in this.state
in the outermost container component, but without the inevitable mess that comes with passing the said state
down through nested layers of props
, and any changes back up the component tree through a rats-nest of handler functions.
Question: Is redux the correct tool to use for a global state store? Are there alternatives out there that behave more akin to react's built-in this.state
, allowing a global application state to be propagated through stateless react components, and updated from throughout the application via a centralized 'switchboard', without the seemingly endless web of boilerplate, constants and switch
statements that come with adopting redux?
5) One single action type? This follow up question is inspired by one of the posted comments.
Question: Could one legitimately (in all seriousness, not just blatantly demonstrating a point) use redux with precisely one action type?
Example - Action creator:
export function someActionCreator(various_params){
return (dispatch, getState => {
// ... business logic here ....
asyncIfThisIfThat().then(val => {
dispatch({
// type: 'UPDATE_STATE', // Don't even bother setting a type
order: val
})
})
)
}
The one universal reducer case:
export default function app(state = initialState, action = {}) {
return Object.assign({}, state, action)
// Just unconditionally merge into state!
}
Seems to me this would provide a globally scoped state object that is automatically mapped to connect
ed components, and one that benefits from all the advantages of immutable state and interoperable with React props
. In this scheme, dispatch
effectively becomes a global setState
.
Note - Please don't take this question wrong - this is certainly not criticism of redux. As a learner I am obviously in no position to judge a technology backed by the expertise of thousands and the support of millions. I have no doubt of its value in the right context.
I'm just sensing the smell of a questionable pattern in my own code and wondering what, if anything I'm doing wrong, or whether I'm using the right tool for the task.
My answer is mostly speaking from my own experience learning redux and using it professionally. The team I was on went down the same path of setter-like actions, and then shifted away to action names that were more event-based and describe what had happened rather than what should happen.
Question: Is it best practice to concentrate business logic in a web of fairly intricate action creators?
This depends on how your actions are named. In your case, your actions are very glorified setters, so all of your business logic is going to live inside of Action Creators. If you name your actions to be more event-like (descriptive about what happened) rather than setters, you're going to have some of the business logic shift into the reducers, and complexity removed from action creators, because event actions naturally feel more re-usable across different reducers. When you do setter actions, the tendency is to have setter-actions that interact with only 1 reducer, and create more setter-actions when you want other reducers to be involved.
If you have an app for a school, and a student is expelled, you'll likely dispatch a REMOVE_STUDENT
and then aDELETE_GRADES_FOR_STUDENT
action. If your action has an event-like name, you may be more inclined to just have a STUDENT_EXPELLED
action that the grades reducer and student roster reducer both act upon it.
There is nothing technically stopping you from having setter-like names, and acting on them in multiple reducers, though. It's just not the tendency that my team fell into when working in Redux and using setter-like names. We didn't want to muddy up the expectations and purity that came from having concise action names, where the impact on state was very clear. REMOVE_STUDENT_GRADES
and DELETE_STUDENT_FROM_ROSTER
felt good.
Question: Is it normal, and acceptable practice to have boilerplate reducers that function merely as glorified setters to the redux store state?
It is normal, but not necessarily correct. This is how our codebase grew initially - we even had standards to name our actions as RESET_...
, SET_...
, REMOVE_...
, ADD_...
, UPDATE...
etc. This seemed to work for a while, until we bumped into cases where we needed multiple reducers to update according to a single action.
You actions will evolve in one of these 2 ways (or both)
Dispatch multiple actions in a row (you must use a library like redux-batch-actions if you want to dispatch multiple actions in a row). We chose not to use this, because it's cumbersome and didn't feel like it scaled very well as our codebase grew in size.
Rename your actions to be more generic and re-usable across different reducers. This is what we ended up doing. Having actions as setters and getters was cumbersome. Dan Abramov and others has expressed their opinion that Redux Actions should be events
(a description of a thing that has happened), rather than instructions
(a description of a thing that should happen). The team I work on agreed with this, and we've moved away from the setters-style of actions. There was much debate about this earlier on when Redux was new.
In scenario 1, you might do something like this:
// student has been expelled from school, remove all of their data
store.dispatch(batchActions(
removeStudentFromClass(student),
removeStudentsGrades(student)
));
// student roster reducer
case REMOVE_STUDENT_FROM_CALLS:
/* ... */
// student grades reducer
case REMOVE_STUDENT_GRADES:
/* ... */
If you go down this path without using Batch Actions, it's an absolute nightmare. Each dispatched event will recompute state, and re-render your app. This falls apart very quickly.
// student has been expelled from school, remove all of their data
store.dispatch(removeStudentFromClass(student));
// app re-rendered, students grades exist, but no student!
store.dispatch(removeStudentsGrades(student));
In the above code, you dispatch an action to remove the student from class, and then the app re-renders. If you have a grades page open, and you can see the students grades, but the student is removed, you're very likely going to reference state in the student roster reducer to grab the student info and that can/will throw a JS error. Bad news. You have the grades for a student of undefined
?! I ran into issues like this myself, and it was part of our motivation for moving to option 2 below. You'll hear about these kinds of states called "intermediate states" and they're problematic.
In scenario 2 your code might look more like this:
store.dispatch(expelStudent(student));
// student roster reducer
case EXPEL_STUDENT:
/* ... */
// student grades reducer
case EXPEL_STUDENT:
/* ... */
With the code above, the student is expelled via the action, and their data is removed from all reducers in 1 step. This scales nicely and your codebase reflects the business terms related to your app that you would talk about day-to-day. You can also perform the same state updates for multiple events if it makes sense from a business logic perspective:
case EXPEL_STUDENT:
case STUDENT_DROPPED_OUT:
case STUDENT_TRANSFERRED:
/* remove student info, all actions must have action.payload.student */
Question: Is it normal for a redux application to rely extensively on thunk'ed action creators, and rarely to fire a standard object action directly?
Yes definitely. As soon as you need to grab a little piece of data from the store in an action creator, it has to become a thunk. Thunks are very common, and should have been part of the redux library.
As our thunks grew in complexity, they got confusing and difficult to easily understand. We started to abuse promises and return values and it was taxing. Testing them was also a nightmare. You have to mock out everything, it's painful.
To solve this problem, we pulled in redux-saga. Redux-saga is easily testable with libraries like redux-saga-test-plan or redux-saga-test-engine (we use test-engine and have contributed to it as needed).
We aren't 100% sagas, and don't aim to be. We still use thunks as needed. If you need to upgrade your action to be a little smarter, and the code is very simple, there's no reason why you shouldn't upgrade that action to a thunk.
As soon as an action creator gets complex enough to warrant some unit testing, redux-saga might come in handy.
Redux-saga does have a rough learning curve to it, and feels quite bizarre at first. Testing sagas manually is miserable. Great learning experience, but I would not ever do it again.
Question: Is redux the correct tool to use for a global state store? Are there alternatives out there that behave more akin to react's built-in this.state, allowing a global application state to be propagated through stateless react components, and updated from throughout the application via a centralized 'switchboard', without the seemingly endless web of boilerplate, constants and switch statements that come with adopting redux?
MobX - I've heard good things about it from people who have your same complaints about Redux (too much boilerplate, too many files, everything is disconnected) I don't use it myself and have not used it, though. There's a good chance that you'll enjoy it more than Redux. It solves the same problem, so if you actually enjoy it more then it may be worth the switch. Developer experience is very important if you're going to work on code for a long time.
I'm okay with the Redux boilerplate and whatnot. The team I worked on has made macros to scaffold out the boilerplate of creating new actions, and we have lots of tests in place so our Redux code is pretty solid. Once you work with it for a while, you internalize the boilerplate and it's not as exhausting.
If you do stick with Redux long term, and are savvy enough to adopt flow on top of redux it's a huge win for long-term maintainability. Fully-typed redux code is amazing to work in, especially for refactoring. It's so easy to refactor a reducer/actionCreators, but forget to update unit test code. If your unit tests are covered by flow, it's going to complain that you're calling a function incorrectly immediately. It's wonderful.
Introducing Flow is a complex hurdle to get over, but well worth it. I wasn't involved in the initial set up, and I assume it's gotten easier to introduce to a codebase, but I'd imagine that it will take some learning and hours. Worth it though. Definitely 100% worth it.
Question: Could one legitimately (in all seriousness, not just blatantly demonstrating a point) use redux with precisely one reducer?
You definitely could, it could work for a small app. It wouldn't scale well for a larger team, and refactoring seems like it would become a nightmare. Splitting the store up into separate reducers lets you isolate responsibilities and concerns.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With