Redux wrote a style guide that
attempts to set some standard practices on how to organize state management
using redux. I think this is an important step to create a platform for further
discussions. I found myself agreeing with most of the recommendations, but there
are a few that I disagree with. I think the primary reason why I disagree with
the style-guide is because virtually every project I use is built with
redux-saga
. From my perspective, redux-thunk
is rarely the right choice
except for very small react applications. Neither arguments building a redux
app are wrong, they are looking at redux from different ways to manage
side-effects.
As a software engineer that builds a lot of front-end apps with other engineers,
it's vitally important that when we build an app, we make it readable and
maintainable. Official recommendations are the north star for software engineers
because it is the culmination of years of experience and it sets a baseline for
how to build an app successfully. Any strong recommendations
from the official
style-guide requires an equally strong reason for going against it.
In this article I will go through the recommendations that I think are
contentious and I want to put forth some recommendations when using
redux-saga
.
Critique #
Put as much logic as possible in reducers #
I built a large app that originally placed a lot of logic inside reducers. This resulted in spaghetti code that was difficult to maintain:
- In order to understand what an action is doing, the developer needs to grep for all occurrences of the action type and then piece together the logic.
- Reducers would listen to actions that were similar, but because the payloads were slightly different they couldn't easily be abstracted into the same functionality, causing duplicated code.
- I have regularly run into situations where a reducer needed access to other slice data that is not easily available inside of a reducer slice.
In general, I think having reducers hold a lot of logic makes that logic easy to test but difficult to maintain, generalize, and refactor over time.
Model actions as events not setters #
Theoretically I agree with this one because it does make the event log easier to read. It will also make time travel debugging -- something I don't find useful -- easier to perform because there would be fewer actions dispatched and they are more traceable to find what triggered them.
However, in practice, I take a hybrid approach. When actions are being dispatched from react, use events. When actions are dispatched from sagas (effects), use setters. This will make an action traceable (find the source of where the action was called) and reducers become generic containers of data that are maintainable, composable. This is how I view reducers: they don't hold logic, they are a database table.
Thinking of reducer slices as database tables provides clarity and consistency
to our state management. This doesn't diminish the utility of redux
it's just
that there are better layers/tools to manage business logic -- hint where we
manage side-effects.
Allow many reducers to respond to the same action #
My hot take is that I think there should be a 1:1 mapping between actions and
reducers. Reducers should own the actions (via createSlice
) and only under
rare exceptions should a reducer listen to outside actions.
Employing this method, redux
becomes a very thin layer of setters to hold data
and tell react
when state has changed. I know this point of view is
controversial and
normally when it comes to building apps as part of a team I don't like to go
against the standards, but this really is being driven by my experience building
large scale web applications with a team of engineers.
To me, the real value of redux is:
- A single global state object that is easily accessible through entire codebase
- One structured way to update state
- A single source of truth that our UI reacts to
- With
react-redux
synchronizing UI with state is handled automatically
Avoid dispatching many actions sequentially #
I want a list of steps that demonstrate how redux is being updated ideally in
the same function. What I don't want is to grep all the reducers for an action
type to see how my state is being updated. This is especially annoying when
employing a modular, feature-based folder structure. We have replaced a single
function that centralizes business logic into a composition of functions that
are scattered throughout the codebase. The logic is broken up which makes the
flow of what is happening harder to understand. What compounds this even worse,
with libraries like redux-saga
, sagas can also listen for those actions and
activate even more side-effects.
Aside: I try to only let sagas listen for events (react-side), not my setters to avoid errant infinite loops.
The counter-argument regularly cited is that sequential dispatches could trigger
multiple react re-renders. This is because each action sequentially hits the
root reducer and could return a new version of the state, triggering an update
event. redux
could have allowed for an array of actions to be dispatched, but
that was ultimately rejected.
Because of this, developers are now required to use
redux-batched-actions. I
think it should have been part of the API and if I were rebuilding redux
from
scratch, it would be an included feature, but I also agree with their
perspective: it could make a lot of people unhappy and there's a user-land
library that makes it work all the same. Regardless, this suggestion and
argument revolving around performance is really because the redux API does not
support dispatching an array of actions. If I could add one thing to the redux
API it would probably be that.
Saga style-guide #
Take the redux
style-guide, remove the ones listed above, and add these for my
unofficial redux-saga
style-guide.
Effects as the central processing unit #
Most of my arguments revolve around using effects as the primary location for
business logic. Whenever I build a react/redux app, beyond the simplest of them,
I need something more powerful and maintainable than redux-thunk
.
redux-toolkit
endorses using redux-thunk
and only under special
circumstances should we reach for something more powerful like redux-saga
.
Personally, I think this should be the opposite. I understand that redux-thunk
is a simple addition (you could inline it easily) with only
14 lines of code
but that's kind of my point. Redux has always struggled with one of the most
important parts of building a web app: side-effects. To be honest I actually
think this is a positive for redux
because it manages state, not side-effects.
Use another tool to solve side-effects. Even Dan
admits that he was
hoping that redux-thunk
would be replaced by something built by the community.
To me, there's no real debate: use redux-saga
. I understand why it cannot be
officially sanctioned: because for simple todo apps -- something the js
community uses as a litmus test to compare implementations -- it is unnecessary.
I get it, but beyond anything simple, you need something more powerful.
Yes, there's a learning curve, the same can be said for redux
and yet we all
still recommend it. redux-saga
uses ES6 generators, they are not that
difficult to grok, and are part of the language. If you are an engineer, it is
your responsibility to learn all language features.
Reducers as setters #
Redux is an object that should be thought of like a database. Reducer slices are
database tables. We should reduce boilerplate with slice helpers
(robodux) by leveraging
new officially sanctioned helpers like createSlice
.
We don't even need to test our reducers anymore because these libraries already did that for us.
This makes reducers predictable, isn't that one of the taglines for redux? A
predictable
state container? Reducers are simplified, and slice helpers cover
90% of our use cases, because we are treating them like database tables.
UI dispatches events, effects dispatch events and setters #
When react
dispatches actions, it should dispatch events, like the redux
style-guide recommends.
When effects like sagas dispatch actions, it can dispatch events and setters. This still provides some traceability and helps centralize business logic into one layer.
Avoid listening to setters #
When you ever have the urge to listen to a setter action in redux-saga
, think
again. This invariably will have the unintended consequence of creating infinite
loops where a saga (A) will keep dispatching setters and saga (B) will listen
for those setters and dispatch actions that trigger saga (A).
Build indexes for your db tables #
Need to have a sorted list of entities that come from an API? Need to group a
subset of entities? First try to create a selector for it. If we need to
preserve the order the API sent us then we can create a reducer EntityId[]
that acts like an index.
Yes, it feels like we are rebuilding a database, but it's not that much work and the manual process allows for performance tweaking which is desirable when building a large application. You will also have to maintain both reducers together. This might sound tedious or prone to errors but in reality these two reducers are coupled by our effects, so it's not that difficult.
1import { call, put } from 'redux-saga/effects';
2import { createTable, createAssign } from 'robodux';
3import { batchActions } from 'redux-batched-actions';
4
5interface Article = {
6 title: string;
7 post: string;
8 author: string;
9}
10
11interface ArticleMap {
12 [key: string]: Article;
13}
14
15// hashmap to store all articles for easy id lookup
16const articles = createTable<ArticleMap>({ name: 'articles' });
17const { set: setArticles } = articles.actions;
18
19// sorted array of article ids that we receive from the API
20const articleOrder = createAssign<string[]>({
21 name: 'articleOrder',
22});
23const { set: setArticleOrder } = articleOrder.actions;
24
25function* onFetchArticles() {
26 const response = yield call(fetch, '/articles');
27 const articles: Article[] = yield call([response, 'json']);
28
29 // preserve order from API
30 const articleOrder = articles.map((article) => article.id);
31
32 // build hashmap of articles (normalize) for easier
33 // lookup and update
34 const articleMap = articles
35 .reduce<ArticleMap>((acc, article) => {
36 acc[article.id] = article;
37 return acc;
38 }, {});
39
40 yield put(
41 batchActions([
42 setArticles(articleMap),
43 setArticleOrder(articleOrder),
44 ]),
45 );
46}
Conclusion #
These are all ideas I use when building large scale web applications and it has
worked extremely well for us. These recommendations are subtle differences
between the official style guide and using redux-saga
.
Congrats! you made it to the end of this article. Do you love
redux-saga
? Try out starfx which is ourrtk-query
/react-query
equivalent. Seriously, it is awesome.
I'd love to read your thoughts so feel free to email me (blog [at] erock.io) about this style-guide.
Links #
- https://redux.js.org/style-guide/style-guide
- https://redux.js.org/faq/code-structure#how-should-i-split-my-logic-between-reducers-and-action-creators-where-should-my-business-logic-go
- https://blog.isquaredsoftware.com/2017/05/idiomatic-redux-tao-of-redux-part-2/#thick-and-thin-reducers
- https://twitter.com/dan_abramov/status/800310164792414208
- https://github.com/reduxjs/redux-toolkit/issues/91#issuecomment-456827660
- https://github.com/reduxjs/redux-toolkit/issues/17#issuecomment-414543588
- https://github.com/neurosnap/saga-query