Idiomatic Redux: Thoughts on Thunks, Sagas, Abstraction, and Reusability

This is a post in the Idiomatic Redux series.


Part of an occasional series of thoughts on good usage patterns for Redux

Intro

I've spent a lot of time discussing Redux usage patterns online, whether it be helping answer questions from learners in the Reactiflux channels, debating possible changes to the Redux library APIs on Github, or discussing various aspects of Redux in comment threads on Reddit and HN. Over time, I've developed my own opinions about what constitutes good, idiomatic Redux code, and I'd like to share some of those thoughts. Despite my status as a Redux maintainer, these are just opinions, but I'd like to think they're pretty good approaches to follow :)

As an initial caveat: I've accumulated a lot of knowledge about Redux, but like all of us, what I know has limits. I've primarily used Redux in practice as part of one application in a specific environment. There's many things I've read about but only used at basic levels, like client-side routing, functional programming, unit testing, and "scaling" applications. What I know is a mixture of practical experience from my own application, and various amounts of theory from what I've read. So, I have a pretty good idea what I'm talking about, but I'm not an absolute expert on everything.

Looking around at the React and Redux community, there's a lot of people out there with way more experience than me. I'd also say most of them are much deeper thinkers than I am. I respect these people. I respect them a lot. People like Dan Abramov, who invented Redux and works on the React team. Or Leland Richardson, who helped build Enzyme, the most popular library for unit testing React components. Or Francois Ward, who's probably forgotten way more about unit testing and functional programming than I'll ever know. Or Em Smith, who has plenty of experience using Redux in a large-scale production application.

I'm going to do something really stupid and publicly disagree with them :)

Since this is going to get rather verbose, I'll summarize things up front.

TL;DR:

The redux-thunk and redux-saga libraries are the most widely-used libraries for "side effects" in Redux. Both provide a place to make AJAX requests, dispatch multiple actions, access the current store state, and run other complex logic. redux-thunk does this by allowing you to pass a function to dispatch(), while redux-saga uses ES6 generators to execute asynchronous logic.

There's been a lot of recent statements arguing that thunks (and sagas) are bad and should almost never be used. As a result, I've seen developers confused and wondering what alternatives they have to implement a given feature.

The concerns being raised are valid, but to balance the discussion, I would argue that thunks are a useful tool in Redux applications, and that developers should not be scared to use thunks in their codebase.

With those thoughts in mind, let's dig into the discussions and see just what has been said about thunks.

The Problem at Hand

My inspiration for this post comes from several sources:

Dan Abramov - "Don't Use getState in thunks"

First, Dan Abramov wrote a Stack Overflow answer back in February, in which he said:

In general accessing state in action creators is an anti-pattern and you should avoid this when possible. The few use cases where I think it’s acceptable is for checking cached data before you make a request, or for checking whether you are authenticated (in other words, doing a conditional dispatch). Passing data such as state.something.items in an action creator is definitely an anti-pattern and is discouraged because it obscured the change history: if there is a bug and items are incorrect, it is hard to trace where those incorrect values come from because they are already part of the action, rather than directly computed by a reducer in response to an action. So do this with care.

Since then, I've seen several people get confused by that comment and asking (paraphrased), "I see that getState is available in thunks, but apparently I can't use it, so how can I do $TASK now?". Now, part of it is that people are interpreting "avoid this when possible" as "NEVER DO THIS", but the fact that Dan said it on Stack Overflow definitely pushes people to take it as gospel.

Leland Richardson - "Thunks/sagas are too powerful"

Second, Leland Richardson started a discussion on Twitter about whether thunks and sagas are a bad idea:

(controversial?) opinion: redux-thunk is too powerful and is a foot-gun. avoid in favor of more convention-based middlewares
i think redux-saga is basically a 1:1 mapping of redux-thunk. just as powerful. just as much of a foot gun.
... its redux thunk that allows you go dispatch crazy...

In another part of the thread, he clarified his thoughts:

the main thing i've seen is just that people get a little dispatch crazy, and end up...
dispatching a bunch of actions sequentially even in the same tick for a given action creator...
this feels really bad to me for a couple of reasons...
1. these usually end up being glorified setter methods. avoiding those is arguably one of the founding motivations of redux
2. in between dispatches, it likely results in an inconsistent store state, since...
...you're treating an action creater like a transaction, except that it's not
3. each dispatch is synchronous, and ends up in potentially a lot of re-rendering on the react side etc.
so overall, the root of it is not that redux-thunk is inherently bad...
...but it gives engineers the power to do pretty much whatever they want...

Shortly thereafter, he created a new middleware called redux-pack. The README for redux-pack says:

redux-pack is a library that introduces promise-based middleware that allows async actions based on the lifecycle of a promise to be declarative. Async actions in redux are often done using redux-thunk or other middlewares. The problem with this approach is that it makes it too easy to use dispatch sequentially, and dispatch multiple "actions" as the result of the same interaction/event, where they probably should have just been a single action dispatch. This can be problematic because we are treating several dispatches as all part of a single transaction, but in reality, each dispatch causes a separate rerender of the entire component tree, where we not only pay a huge performance penalty, but also risk the redux store being in an inconsistent state. redux-pack helps prevent us from making these mistakes, as it doesn't give us the power of a dispatch function, but allows us to do all of the things we were doing before. ...and ends up making an "action creator" nothing more than a "literally anything could be happening here" function...

During that same Twitter discussion, Dan Abramov made several comments about thunks:

it was literally the "I hope people will come up with something better" solution, didn't expect it to catch on so much

Good thread on the problems with Redux Thunk. Indeed “glorified setters” is a very common misuse of Redux.
If your actions creator names start with set* and you often call multiple in a row, you might be missing the point of using Redux.
The point is to decouple “what happened” from “how the state changes”. “Setter” action creators defeat the purpose, conflating the two.
They are also inefficient and may lead to inconsistent UI. Dispatching multiple times is an escape hatch and should be used sparingly.

[getState in thunks is] useful for checking assumptions are still valid after async response comes in

Francois Ward - "Using getState breaks 1-way data flow and obscures actions"

Next, in a recent chat in the Redux channel on Reactiflux, a user came in asking about Dan's "avoid getState in thunks" quote. Afterwards, Francois Ward, Jim Bolla, and I had an extended debate on the relative merits of accessing state in thunks and sagas. A few of the relevant comments:

[10:29 PM] Francois Ward: I don't think he's wrong. Your UI generates actions, an event log, that gets reduced to a state that is used to generate/update a new UI. The only reason to use the state in an action creator is to get the interim computations that a previous action did within an action, which essentially means you're using the reduced state to create more actions...which doesn't make a whole lot of sense and breaks the 1 way nature of the architecture.
It also means you're doing a lot more in the reducer than just reducing the action log to a state, breaking its semantic.
Finally, even if you needed it, when the state updates, the UI will update and you can get the new version in componentWillReceiveProps, from which you can dispatch the new actions with it, keeping the data flow.
[10:30 PM] Francois Ward: its a nice escape hatch when you REALLY need it, but its excessively rare.
[10:34 PM] Francois Ward: the flow is meant to be UI -> actions -> reducers -> UI, not UI -> actions -> reducers -> actions -> reducers -> UI

[10:42 PM] Francois Ward: So, given a fully functioning UI with a given state (overloaded term here: I mean the props and context), you would be unable to generate a valid event log with this architecture.
[10:42 PM] Francois Ward: your UI is now tightly coupled to your reducer.
[10:43 PM] Francois Ward: it can't behave without a computed store.
[10:43 PM] Francois Ward: looking at the UI and the event log, you also can't tell where data came from.

[10:53 PM] Francois Ward: I said it already: whatever data you need to generate the computed payload of your action (which is a side effect of the UI when you think about it), needs to come from the UI.

[11:01 PM] JimBolla: I don't like the idea that I have give my UI extra data just to pass along to action creators. Components should only be responsible for rendering UI. Application behavior should be in the app layer. IMO action creators should be as self contained as possible. I want as much of my apps behavior out of React as possible. The UI is always the layer most likely to get wholesale replaced.
[11:12 PM] JimBolla: Consider this... an action creator that needs 4 pieces of data, some of that data lives in the store. There are 5 components that might call that action creator. Given your suggestion, each component then has to select all the data needed to pass to that action creator IF it calls it. What if selecting that data is expensive and that chance that action creator is called is low? Now you're needlessly slowing your app. What if a new requirement dictates that action creator now needs an addition piece of data that lives in the store. Now you have to go update all the components to ensure they pass the additional parameter.
[11:28 PM] JimBolla: My goal is to get as much code out of the components as possible, this includes shuttling parameters.

Em Smith - "thunks lead to duplicate async logic"

As I was working on this post, Em Smith published You may not need to thunk. In it, she argues that thunks are overused, as well as giving us less info about why things happened in the system:

By using thunked action creators, we start passing around functions throughout our application, which means that our actions can no longer easily be serialised, which impedes the ability to log the actions and complicates debugging, to paraphrase Mark Erikson:

The idea is that we aren’t seeing what logic that led up to the action being dispatched, as opposed to dispatching an action that acts as a “signal” to kick off the more complex logic.

By not having this initial action logged, we don’t know why we started to do something.

She also argues that using them for async requests results in painful duplication of request handling logic:

... most likely your application talks to an API at multiple points, across multiple files, which means every action creator is duplication the logic to perform API requests and connect them to your reducers to persistent different states in the store. ... [this action], in a piece of custom middleware, is transformed into a different set of actions. This centralises all the logic for talking to the reddit API in our application, and gives us actions which describe what we’re wanting to do, rather than doing a complicated flow of logic.

Reddit - "thunks aren't good for decoupling"

Finally, there was a recent Reddit thread on "fat containers vs fat thunks". In that thread, someone commented on reusability:

It's all dandy until you need to decouple your now big app into reusable sub-apps. Then you realize that your user profile actions know too much - about which action should be dispatched for errors, modals, auth etc. I am looking into building easy decoupled redux apps and there is not much to do besides some form of DI

Summing Up the Problems

Going over all these comments, I see a variety of major themes being raised:

  • Thunks and sagas let you run arbitrary code that can do anything
  • Using store state as a source of values for actions can obscure where the data came from
  • Multiple dispatches cause excess re-rendering
  • Multiple dispatches that are supposed to go together "transaction"-style could be interrupted, leaving the application in an unexpected state
  • Accessing store state makes testing harder (as does composing thunks together)
  • Accessing store state "breaks uni-directional data flow"
  • Thunks are too coupled and are bad for scalability
  • Using thunks for async requests results in duplicated logic, and potentially less visible info in the dispatched action trail

That's... a lot of different complaints about thunks. As I said earlier, almost all of those are valid statements and concerns.

So, does that mean we should go uninstall redux-thunk en masse, never to dispatch a function again? Not so fast :) First, time to take a detour to look at the thoughts of a couple people who definitely much deeper thinkers than I am. (Admittedly, that category includes a lot of people...)

Abstractions and Power

At React Europe 2016, Cheng Lou gave an incredible talk entitled "On the Spectrum of Abstraction". That talk is available for viewing here, and I'd encourage you to take the time to watch it. It's only a half-hour, but it is mind-blowing. It's worth re-watching two or three times, to let the ideas sink in. (For those who are interested, I wrote down a transcript/notes of the talk here).

I found a good summary of Cheng Lou's talk, which I'll quote here for reference:

Cheng's talk provided a useful way to evaluate technology choices in different contexts by placing them on a spectrum of abstraction and understanding the trade-offs that come with choosing technologies at different points on that spectrum. The "spectrum of abstraction" refers to the fact that some technologies solve very specific problems (eg. The clock app on a phone) and others are much more abstract but are useful for a broader class of uses (eg. a Promises library for JavaScript).

Cheng framed it as a kind of optimization problem to minimize the overall cognitive costs of the codebase in order to satisfy an evolving set of use cases. On the one hand, choosing unnecessary abstractions imposes a cognitive cost because of the gap between the abstraction and the problem being solved. On the other hand choosing too many problem-specific tools imposes other costs.

There was an interesting look at the trade-offs around the power that different abstractions have. eg. Declarative systems for specifying things limit the developer's freedom but often enable useful tooling and optimizations because of those constraints.

Similarly, Brian Lonsdorf (aka "DrBoolean"), recently tweeted:

The more you limit a system, the simpler it will be to understand. Less possibility = less complexity. Avoid power, embrace constraints.

In the presentation, the various levels of abstraction are represented as a tree. Leaf nodes represent specific use cases, and tools that only cover one or two leaf nodes are "more useful" and "less powerful". Nodes higher in the tree represent more abstract tools that cover a wider range of use cases, and are "less useful" and "more powerful". Per the presentation, the terms "more/less powerful" and "more/less useful" are not comparisons of whether a thing is good or bad. They are simply descriptions of how concrete and focused vs how abstract and broad a thing is.

Assuming I've understood Cheng Lou's terms correctly (which is still up for debate), I believe we can say that:

  • Thunks are more powerful, and thus higher in the tree of abstraction, because they can be used for every conceivable use case
  • Sagas are both more and less powerful than thunks, and so probably about the same level in the tree. Their declarative usage is somewhat more specific, so in that sense they'd be a little lower. On the other hand, given that sagas can respond to dispatched actions, not just dispatch them, in some ways sagas are more powerful and cover more use cases.
  • A middleware like redux-pack is very useful and much less powerful, and thus a leaf on the tree. This is because it's intended for a very specific use case: tracking and dispatching actions for async requests.

With those thoughts in mind, let's look at the concerns about thunks in further detail.

Answering the Concerns

I think we can group up some of these concerns into categories. From there, we can talk about what these concerns mean, how serious they are, and what solutions might be available.

Power

Related concerns:

  • Thunks and sagas let you run arbitrary code that can do anything

This description is 100% correct. Thunks and sagas both effectively hand you a bucket and a shovel and say "There's the beach, go have fun making sand castles!". However, the real question is whether this is actually a bad thing, a good thing, or neutral.

I would say that the ability for thunks to do anything is absolutely a good thing. A middleware like redux-pack or one of the many other "promise lifecycle"-type middlewares by definition only works for certain use cases - dispatching a sequence of actions representing the progress of an async action. If that use case is what you're trying to solve, great, you're in luck! If not... well, you need something with more flexibility.

(As an interesting observation: Cheng Lou's talk seems to suggest that more powerful tools generally involve more abstraction and are usually harder to understand. In a somewhat ironic reversal, redux-pack, a less powerful and more useful tool, takes a bit more work to understand than redux-thunk, which is incredibly simple in both concept and implementation. I'm not sure this actually means anything important, just an interesting thought.)

Coupling, Testing, and Scalability

Related concerns:

  • Thunks are too coupled and are bad for scalability
  • Accessing store state makes testing harder (as does composing thunks together)

There's definitely some validity to these concerns, but I feel like the original comments that I saw are over-stating the problems.

Part of the issue here is that Redux's single store and middleware pipeline is great for application-wide concerns, but comes with the tradeoff that full encapsulation and composition of logic becomes correspondingly harder. Full encapsulation and Lego-like reusability of logic and components in Redux isn't impossible, but it does take a lot of extra work to accomplish. There's no easy definitive solution for that yet, although there's a lot of ongoing community research and experimentation with approaches.

To specifically address some of the concerns raised by the Reddit comment earlier: most applications don't need to be divided up into "reusable sub-apps", and even if that is a use case you need to deal with, it doesn't necessarily imply that "your $FEATURE actions know too much about $OTHER_FEATURE". That might be the case, but that's really a question of how you've architected your app and written your code, not an inherent problem with thunks.

Now, it is true that if you're composing multiple thunks together (ie, bigComplexThunk() itself dispatches smallerThunk1(), smallerThunk2(), and smallerThunk3()), those are now pretty tightly coupled. However, I would say this is not a huge problem, and not a reason to stop using thunks. Sure, the platonic ideal of "good code" may be perfectly decoupled functions all unit testable in isolation, but if a codebase doesn't reach that ideal, it won't kill the application. Looking at some of my own code that composes thunks together, most of the "smaller" pieces are decently testable themselves, and while I definitely lack experience doing unit testing overall, I could probably write tests for some of the "bigger" thunks.

As for the complaints about usage of getState being a problem in thunks (and its equivalent select() in sagas): Yeah, I see the concern. In both cases, the code accessing state would would probably count as "impure" code in that the data wasn't directly passed in as arguments, and we've certainly learned that pure functions are easier to test. However, unlike reducers, action creators are not required to be 100% pure functions. In addition, the fact that the state is injectable should make testing somewhat easier. Yes, you do need to have a representative store state prepared to test this code properly, but that can now be used either by using something like redux-mock-store, or by directly passing a fake getState function into the thunk (or the equivalent approach for passing a state into a saga).

Looking at these concerns, I would point to the phrase "The perfect is the enemy of the good". Sure, making our code more testable and more decoupled is almost always a good thing, but code that isn't 100% perfect in that regard can still be entirely usable for the intended purpose.

State Tracing

Related concerns:

  • Using store state as a source of values for actions can obscure where the data came from
  • Using thunks results in potentially less visible info in the dispatched action trail

I sorta see where these complaints are coming from, but I don't agree that they're meaningful problems.

Re-reading the quotes earlier in the article, the best I can make out of the "obscure the data" argument is that if a value in the store happens to be wrong, putting it into an action just perpetuates the problem. And, uh... yes? Why is that bad, exactly? If it's wrong, and it's put into a dispatched action, you should be able to see the value during development and debugging, glance at the code that dispatched the action, see it came from the store, and track back through the previous actions to see what went wrong. I'm also not exactly sure why this would be any different than the more general case of "the logic that led to dispatching an action passed along a bad value", and I would almost think that knowing the value came from the store originally would make it easier to trace the flow (as opposed to a value coming from a random chain of promises or something). Besides, one of Redux's selling points is that actions are usually only dispatched in a couple places, so a search for the action type should point you to where it came from pretty quickly.

As for the "less visible info" concern: I think the idea here is that "signal" actions used to trigger listening sagas effectively act as additional logging for the application, giving you that much more of an idea what was going on internally. I suppose that's true, but I don't see that as a critical part of trying to debug an application. Not everyone uses redux-saga, and even for those who do, you should probably have other logging set up in your application. Looking for "signal" actions is probably helpful, but not having them available just doesn't seem like a deal-breaker to me.

Multiple Dispatching

Related concerns:

  • Multiple dispatches cause excess re-rendering
  • Multiple dispatches that are supposed to go together "transaction"-style could be interrupted, leaving the application in an unexpected state

Both of these concerns are correct. I do have to throw a small caveat in here. React-Redux's connect function does a lot of work to ensure that the "plain" components only re-render when they actually have new props, either from the parent component or from mapState. If there are multiple dispatches, most likely a given component's mapState function will return the same results as before, unless the dispatched action directly resulted in changes to data that this component cared about.

Let me bring out a semi-concrete example. In my own app, I've built up a number of "primitive" actions that are often reused as part of larger tasks, such as ENTITY_CREATE, ENTITY_UPDATE, EDIT_ITEM_START, MODAL_SHOW, and MODAL_CLOSE. For a specific feature, I have a dialog that shows a list of items. That list can itself pop up a second dialog to let the user enter values and create a new item. I have a menu item that allows the user to jump straight to the "new item" dialog. The thunk attached to that menu item looks like this (names altered for clarity of the example):

export function showNewItemDialog() {
    return dispatch => {
        dispatch(showDialog("ItemManagement"));
        dispatch(showDialog("NewItem"));
        dispatch(requestItemsListFromServer());
    };
}

In that example, running the thunk will cause three dispatches to hit the store: showing the first dialog, showing the second dialog, and loading the data from the server.

Going back to the "re-rendering" question: yes, by default every call to dispatch will result in every connected component instance's mapState function being run. It's entirely likely that the majority of those calls are, in a sense, "wasted" effort, and so one way to theoretically improve performance is to minimize the number of dispatches. This is usually done by batching up dispatches, and there's a lot of ways to approach that idea:

  • Dispatching a single action that multiple reducers listen for and all independently respond to appropriately
  • Dispatching a "wrapper" action that a higher-order reducer unwraps, then runs each of the "wrapped" actions
  • Using a store enhancer to allow dispatching an array of actions, with a single notification
  • Using a store enhancer to debounce notifications
  • Using a middleware to debounce dispatches

Another way to improve perf is to use memoized selector functions inside of mapState to cut down on expensive work and ensure that cached values are returned if appropriate.

However, like all performance and optimization efforts, the real question is whether this is a meaningful performance problem in the first place. If you've got a CRUD app that's mostly sitting there doing nothing, then a couple extra dispatches probably aren't going to slow anything down. If you've got a real-time websocket-connected application spewing out hundreds of update events a second, the couple dispatches in this thunk or saga are going to be completely outweighed by all the data updates coming in. So sure, fewer dispatches is generally a good thing, but most of the time this is not going to be a performance bottleneck.

Looking at the second concern: yes, it's technically true that after each of the "steps" the app is sorta-kinda in an "intermediate" state compared to the desired end result. In this specific example, that's really not a problem, but I could see that there could be cases where that "intermediate" state is unexpected and breaks things.

Part of that is that a Redux store, state, and reducer are a kind of state machine, but probably not really a fully formally specified one. There's likely some ad-hoc assumptions encoded in the state tree. In my own app, I have some actions that are only intended to be dispatched while I'm actively editing an item. I don't have all of those actions set up with sanity checks in the action creators at the moment, because I'm relying on other parts of the app to ensure that the actions are only dispatched if appropriate (such as input fields or buttons being disabled unless isEditing is true). So yes, I could envision some unformalized assumptions being broken in some "intermediate" state, especially if there's some asynchronous work going on.

Still, in the end, I'm not worried about these concerns. If perf is an issue with dispatching, it can be handled with one of the various batched dispatch approaches. If your actions can result in some "unstable" states, you probably want to rethink how that's being handled anyway (or, apply the same batching concepts to implement "transactions").

Other Concerns

Related concerns:

  • Using thunks for async requests results in duplicated logic
  • Accessing store state "breaks uni-directional data flow"

Time for the last (and actually least) couple concerns.

Sure, if you've got a whole bunch of really complicated response processing logic, then it probably should be centralized. That said, I would guess that the vast majority of applications are going to be much simpler - make a request, dispatch an action, done. Not much to centralize there.

As for "accessing state breaking uni-directional data flow", I really don't agree with this at all. The idea of uni-directional data flow is that data updates are applied in one place, and propagate through the system from there. In a plain React application, that means callbacks passing an event up a component tree to an ancestor, calling setState(), and passing the new data back down as props. In a Redux app, that means that all the "write" logic is encapsulated in the root reducer function, the reducer is run in response to dispatched actions, and all connected components read the updated state from the store afterwards. This is in contrast to a typical Angular app that would use 2-way input binding to directly modify a model, or a Backbone app where every view can call this.model.set("someField", someValue) at any time.

As far as I can see, reading state in a thunk or saga does not break uni-directional data flow. The action is still dispatched, the reducer is still run, and the components still read the updated state. It's not like the thunk is directly modifying the state itself. I'll grant that it's not as strict as Elm, where every piece of data is passed down from view to view explicitly, but I'm not seeing a problem here.

Final Thoughts

As I said earlier, these concerns are generally valid observations that have been raised by some pretty smart people. I just don't think these are sufficient reasons to stop using thunks and sagas, stop accessing state in the process, or completely avoid multiple dispatches.

Dan Abramov recently tweeted about people over-interpreting "container" vs "presentational" components. In that tweet thread, he wrote:

It’s okay to convert a functional component to a class when you need lifecycles or state. That’s why React exists in the first place. Does it limit reuse? Sometimes maybe. Do you plan to reuse it? If you don’t know, don’t worry. You can always extract a pure part later.

I believe the same principle applies with thunks and sagas. Not every application is highly performance sensitive. Not every application needs centralized async request handling. Not every chunk of logic involves an async request with a predictable lifecycle of actions to dispatch. Not every application needs every last line of code to be 100% pure and perfectly unit testable. Not every application needs to be split into completely encapsulated, composable, and reusable pieces. Not every problem needs to be solved with a tool purpose-built for that exact use case.

As developers, it's our job to look at a problem, evaluate possible solutions, and pick the solution that best fits our use case. Sometimes that may be a hyper-specialized library written for this exact purpose. Other times, it might be a general-purpose tool that can be adapted for the job.

Ultimately, I still believe thunks and sagas help solve real problems, and are good tools to help build Redux apps. I encourage Redux users to make use of these tools wherever they are needed.

Further Information


This is a post in the Idiomatic Redux series. Other posts in this series:


Author Avatar

Mark Erikson

Collector of interesting links, answerer of questions