Pros/cons of using redux-saga with ES6 generators vs redux-thunk with ES2017 async/await
Asked Answered
F

11

579

There is a lot of talk about the latest kid in redux town right now, redux-saga/redux-saga. It uses generator functions for listening to/dispatching actions.

Before I wrap my head around it, I would like to know the pros/cons of using redux-saga instead of the approach below where I'm using redux-thunk with async/await.

A component might look like this, dispatch actions like usual.

import { login } from 'redux/auth';

class LoginForm extends Component {

  onClick(e) {
    e.preventDefault();
    const { user, pass } = this.refs;
    this.props.dispatch(login(user.value, pass.value));
  }

  render() {
    return (<div>
        <input type="text" ref="user" />
        <input type="password" ref="pass" />
        <button onClick={::this.onClick}>Sign In</button>
    </div>);
  } 
}

export default connect((state) => ({}))(LoginForm);

Then my actions look something like this:

// auth.js

import request from 'axios';
import { loadUserData } from './user';

// define constants
// define initial state
// export default reducer

export const login = (user, pass) => async (dispatch) => {
    try {
        dispatch({ type: LOGIN_REQUEST });
        let { data } = await request.post('/login', { user, pass });
        await dispatch(loadUserData(data.uid));
        dispatch({ type: LOGIN_SUCCESS, data });
    } catch(error) {
        dispatch({ type: LOGIN_ERROR, error });
    }
}

// more actions...

// user.js

import request from 'axios';

// define constants
// define initial state
// export default reducer

export const loadUserData = (uid) => async (dispatch) => {
    try {
        dispatch({ type: USERDATA_REQUEST });
        let { data } = await request.get(`/users/${uid}`);
        dispatch({ type: USERDATA_SUCCESS, data });
    } catch(error) {
        dispatch({ type: USERDATA_ERROR, error });
    }
}

// more actions...
Friseur answered 21/1, 2016 at 17:45 Comment(6)
See also my answer comparing redux-thunk to redux-saga here: https://mcmap.net/q/53469/-why-do-we-need-middleware-for-async-flow-in-reduxArmstrong
What is the :: before your this.onClick do?Crowther
@ZhenyangHua it is a short-hand for binding the function to the object (this), aka this.onClick = this.onClick.bind(this). The longer form is usually recommended to do in the constructor, as the short-hand re-binds on every render.Friseur
I see. thanks! I see folks using bind() a lot to pass this to the function, but I started using () => method() now.Crowther
@Friseur did you find redux saga useful? After the explanation I couldn't find any benefit.Hannibal
@Hannibal I used redux & redux-saga in production for a while, but actually migrated to MobX after a couple of months because less overheadFriseur
F
506

In redux-saga, the equivalent of the above example would be

export function* loginSaga() {
  while(true) {
    const { user, pass } = yield take(LOGIN_REQUEST)
    try {
      let { data } = yield call(request.post, '/login', { user, pass });
      yield fork(loadUserData, data.uid);
      yield put({ type: LOGIN_SUCCESS, data });
    } catch(error) {
      yield put({ type: LOGIN_ERROR, error });
    }  
  }
}

export function* loadUserData(uid) {
  try {
    yield put({ type: USERDATA_REQUEST });
    let { data } = yield call(request.get, `/users/${uid}`);
    yield put({ type: USERDATA_SUCCESS, data });
  } catch(error) {
    yield put({ type: USERDATA_ERROR, error });
  }
}

The first thing to notice is that we're calling the api functions using the form yield call(func, ...args). call doesn't execute the effect, it just creates a plain object like {type: 'CALL', func, args}. The execution is delegated to the redux-saga middleware which takes care of executing the function and resuming the generator with its result.

The main advantage is that you can test the generator outside of Redux using simple equality checks

const iterator = loginSaga()

assert.deepEqual(iterator.next().value, take(LOGIN_REQUEST))

// resume the generator with some dummy action
const mockAction = {user: '...', pass: '...'}
assert.deepEqual(
  iterator.next(mockAction).value, 
  call(request.post, '/login', mockAction)
)

// simulate an error result
const mockError = 'invalid user/password'
assert.deepEqual(
  iterator.throw(mockError).value, 
  put({ type: LOGIN_ERROR, error: mockError })
)

Note we're mocking the api call result by simply injecting the mocked data into the next method of the iterator. Mocking data is way simpler than mocking functions.

The second thing to notice is the call to yield take(ACTION). Thunks are called by the action creator on each new action (e.g. LOGIN_REQUEST). i.e. actions are continually pushed to thunks, and thunks have no control on when to stop handling those actions.

In redux-saga, generators pull the next action. i.e. they have control when to listen for some action, and when to not. In the above example the flow instructions are placed inside a while(true) loop, so it'll listen for each incoming action, which somewhat mimics the thunk pushing behavior.

The pull approach allows implementing complex control flows. Suppose for example we want to add the following requirements

  • Handle LOGOUT user action

  • upon the first successful login, the server returns a token which expires in some delay stored in a expires_in field. We'll have to refresh the authorization in the background on each expires_in milliseconds

  • Take into account that when waiting for the result of api calls (either initial login or refresh) the user may logout in-between.

How would you implement that with thunks; while also providing full test coverage for the entire flow? Here is how it may look with Sagas:

function* authorize(credentials) {
  const token = yield call(api.authorize, credentials)
  yield put( login.success(token) )
  return token
}

function* authAndRefreshTokenOnExpiry(name, password) {
  let token = yield call(authorize, {name, password})
  while(true) {
    yield call(delay, token.expires_in)
    token = yield call(authorize, {token})
  }
}

function* watchAuth() {
  while(true) {
    try {
      const {name, password} = yield take(LOGIN_REQUEST)

      yield race([
        take(LOGOUT),
        call(authAndRefreshTokenOnExpiry, name, password)
      ])

      // user logged out, next while iteration will wait for the
      // next LOGIN_REQUEST action

    } catch(error) {
      yield put( login.error(error) )
    }
  }
}

In the above example, we're expressing our concurrency requirement using race. If take(LOGOUT) wins the race (i.e. user clicked on a Logout Button). The race will automatically cancel the authAndRefreshTokenOnExpiry background task. And if the authAndRefreshTokenOnExpiry was blocked in middle of a call(authorize, {token}) call it'll also be cancelled. Cancellation propagates downward automatically.

You can find a runnable demo of the above flow

Flapdoodle answered 21/1, 2016 at 20:12 Comment(7)
@yassine where is the delay function coming from? Ah, found it: github.com/yelouafi/redux-saga/blob/…Ganef
The redux-thunk code is quite readable and self-explained. But redux-sagas one is really unreadable, mainly because of those verb-like functions: call, fork, take, put...Iroquois
@syg, I agree that call, fork, take, and put can be more semantically friendly. However, it is those verb-like functions that make all the side-effects testable though.Crowther
FYI, js bin needs updating: "ReferenceError: Redux is not defined"Lampley
@Lampley It's fixed, been fixed since 2016 08 11.Convince
@syg still a function with those weird verbs functions are more readable than a function with deep promises chainAdige
those "weird" verbs also help you conceptualize the saga's relationship to the messages coming out of redux. you can take message types out of redux--often to trigger the next iteration, and you can put new messages back in to broadcast the result of your side effect.Thrum
R
116

I will add my experience using saga in production system in addition to the library author's rather thorough answer.

Pro (using saga):

  • Testability. It's very easy to test sagas as call() returns a pure object. Testing thunks normally requires you to include a mockStore inside your test.

  • redux-saga comes with lots of useful helper functions about tasks. It seems to me that the concept of saga is to create some kind of background worker/thread for your app, which act as a missing piece in react redux architecture(actionCreators and reducers must be pure functions.) Which leads to next point.

  • Sagas offer independent place to handle all side effects. It is usually easier to modify and manage than thunk actions in my experience.

Con:

  • Generator syntax.

  • Lots of concepts to learn.

  • API stability. It seems redux-saga is still adding features (eg Channels?) and the community is not as big. There is a concern if the library makes a non backward compatible update some day.

Rebbeccarebe answered 10/6, 2016 at 7:41 Comment(5)
Just wanna make some comment, action creator need not to be pure function, which has been claimed by Dan himself many times.Replay
As of now, redux-sagas are very much recommended as the usage and the community has expanded. Also, the API has become more mature. Consider removing the Con for API stability as an update to reflect the current situation.Convince
saga has more starts than thunk and its last commit is after thunk tooSelry
Yes, FWIW redux-saga now has 12k stars, redux-thunk has 8kDeviltry
I'm going to add another challenge of sagas, is that sagas are entirely decoupled from actions and action creators by default. While Thunks directly connect action creators with their side effects, sagas leave action creators totally separated from the sagas that listen for them. This has technical advantages, but can make code much more difficult to follow, and can blur some of the unidirectional concepts.Alphanumeric
E
38

I'd just like to add some comments from my personal experience (using both sagas and thunk):

Sagas are great to test:

  • You don't need to mock functions wrapped with effects
  • Therefore tests are clean, readable and easy to write
  • When using sagas, action creators mostly return plain object literals. It is also easier to test and assert unlike thunk's promises.

Sagas are more powerful. All what you can do in one thunk's action creator you can also do in one saga, but not vice versa (or at least not easily). For example:

  • wait for an action/actions to be dispatched (take)
  • cancel existing routine (cancel, takeLatest, race)
  • multiple routines can listen to the same action (take, takeEvery, ...)

Sagas also offers other useful functionality, which generalize some common application patterns:

  • channels to listen on external event sources (e.g. websockets)
  • fork model (fork, spawn)
  • throttle
  • ...

Sagas are great and powerful tool. However with the power comes responsibility. When your application grows you can get easily lost by figuring out who is waiting for the action to be dispatched, or what everything happens when some action is being dispatched. On the other hand thunk is simpler and easier to reason about. Choosing one or another depends on many aspects like type and size of the project, what types of side effect your project must handle or dev team preference. In any case just keep your application simple and predictable.

Embargo answered 12/10, 2017 at 22:6 Comment(0)
D
32

Update in July 2020:

During the last 16 months, maybe the most notable change in the React community is React hooks.

According to what I observe, in order to gain better compatibility with functional components and hooks, projects (even those large ones) would tend to use:

  1. hook + async thunk (hook makes everything very flexible so you could actually place async thunk in where you want and use it as normal functions, for example, still write thunk in action.ts and then useDispatch() to trigger the thunk: https://mcmap.net/q/55978/-how-to-make-async-call-in-react-redux-hooks-with-thunk),
  2. useRequest,
  3. GraphQL/Apollo useQuery useMutation
  4. react-fetching-library
  5. other popular choices of data fetching/API call libraries, tools, design patterns, etc

In comparison, redux-saga doesn't really provide significant benefit in most normal cases of API calls comparing to the above approaches for now, while increasing project complexity by introducing many saga files/generators (also because the last release v1.1.1 of redux-saga was on 18 Sep 2019, which was a long time ago).

But still, redux-saga provides some unique features such as racing effect and parallel requests. Therefore, if you need these special functionalities, redux-saga is still a good choice.


Original post in March 2019:

Just some personal experience:

  1. For coding style and readability, one of the most significant advantages of using redux-saga in the past is to avoid callback hell in redux-thunk — one does not need to use many nesting then/catch anymore. But now with the popularity of async/await thunk, one could also write async code in sync style when using redux-thunk, which may be regarded as an improvement in redux-thunk.

  2. One may need to write much more boilerplate codes when using redux-saga, especially in Typescript. For example, if one wants to implement a fetch async function, the data and error handling could be directly performed in one thunk unit in action.js with one single FETCH action. But in redux-saga, one may need to define FETCH_START, FETCH_SUCCESS and FETCH_FAILURE actions and all their related type-checks, because one of the features in redux-saga is to use this kind of rich “token” mechanism to create effects and instruct redux store for easy testing. Of course one could write a saga without using these actions, but that would make it similar to a thunk.

  3. In terms of the file structure, redux-saga seems to be more explicit in many cases. One could easily find an async related code in every sagas.ts, but in redux-thunk, one would need to see it in actions.

  4. Easy testing may be another weighted feature in redux-saga. This is truly convenient. But one thing that needs to be clarified is that redux-saga “call” test would not perform actual API call in testing, thus one would need to specify the sample result for the steps which may be used after the API call. Therefore before writing in redux-saga, it would be better to plan a saga and its corresponding sagas.spec.ts in detail.

  5. Redux-saga also provides many advanced features such as running tasks in parallel, concurrency helpers like takeLatest/takeEvery, fork/spawn, which are far more powerful than thunks.

In conclusion, personally, I would like to say: in many normal cases and small to medium size apps, go with async/await style redux-thunk. It would save you many boilerplate codes/actions/typedefs, and you would not need to switch around many different sagas.ts and maintain a specific sagas tree. But if you are developing a large app with much complex async logic and the need for features like concurrency/parallel pattern, or have a high demand for testing and maintenance (especially in test-driven development), redux-sagas would possibly save your life.

Anyway, redux-saga is not more difficult and complex than redux itself, and it does not have a so-called steep learning curve because it has well-limited core concepts and APIs. Spending a small amount of time learning redux-saga may benefit yourself one day in the future.

Descartes answered 27/3, 2019 at 13:26 Comment(6)
I agree with your 2020 update, I've been using saga for 1 year before switching to a minimalist hook api library, which can handle side effects really well, without adding more complexity. If interested: github.com/marcin-piela/react-fetching-library (I'm not the author of this library)Burhans
@Descartes - can you please provide a code example of what you mean by "hook + async thunk"?Chart
Is Saga still 'recommended' considering React Hooks? If I am on the fence considering this argument, that might be a key factor for me...Kenyatta
Personally speaking, I would recommend using hook + thunk for most normal cases, but it would still be better to check the sage doc by yourself and see if your project need any of its special features. @KenyattaDescartes
I've chosen it merely because of it's simplicity, testability, and that it was 'recommended', but nothing else specific. To you what would be the determining factor in not using it for those things alone?Kenyatta
Mainly due to its generator syntax. When we already have async/await everywhere in our project and async/await could also be used to solve all our data fetching, there would be no need to introduce another mechanism just for the API part.Descartes
D
9

Having reviewed a few different large scale React/Redux projects in my experience Sagas provide developers a more structured way of writing code that is much easier to test and harder to get wrong.

Yes it is a little wierd to start with, but most devs get enough of an understanding of it in a day. I always tell people to not worry about what yield does to start with and that once you write a couple of test it will come to you.

I have seen a couple of projects where thunks have been treated as if they are controllers from the MVC patten and this quickly becomes an unmaintable mess.

My advice is to use Sagas where you need A triggers B type stuff relating to a single event. For anything that could cut across a number of actions, I find it is simpler to write custom middleware and use the meta property of an FSA action to trigger it.

Dramshop answered 14/6, 2018 at 21:4 Comment(0)
E
5

Thunks versus Sagas

Redux-Thunk and Redux-Saga differ in a few important ways, both are middleware libraries for Redux (Redux middleware is code that intercepts actions coming into the store via the dispatch() method).

An action can be literally anything, but if you're following best practices, an action is a plain javascript object with a type field, and optional payload, meta, and error fields. e.g.

const loginRequest = {
    type: 'LOGIN_REQUEST',
    payload: {
        name: 'admin',
        password: '123',
    }, };

Redux-Thunk

In addition to dispatching standard actions, Redux-Thunk middleware allows you to dispatch special functions, called thunks.

Thunks (in Redux) generally have the following structure:

export const thunkName =
   parameters =>
        (dispatch, getState) => {
            // Your application logic goes here
        };

That is, a thunk is a function that (optionally) takes some parameters and returns another function. The inner function takes a dispatch function and a getState function -- both of which will be supplied by the Redux-Thunk middleware.

Redux-Saga

Redux-Saga middleware allows you to express complex application logic as pure functions called sagas. Pure functions are desirable from a testing standpoint because they are predictable and repeatable, which makes them relatively easy to test.

Sagas are implemented through special functions called generator functions. These are a new feature of ES6 JavaScript. Basically, execution jumps in and out of a generator everywhere you see a yield statement. Think of a yield statement as causing the generator to pause and return the yielded value. Later on, the caller can resume the generator at the statement following the yield.

A generator function is one defined like this. Notice the asterisk after the function keyword.

function* mySaga() {
    // ...
}

Once the login saga is registered with Redux-Saga. But then the yield take on the the first line will pause the saga until an action with type 'LOGIN_REQUEST' is dispatched to the store. Once that happens, execution will continue.

For more details see this article.

Edra answered 31/7, 2019 at 15:11 Comment(0)
M
3

To give this answer some context: Hi, I'm a Redux maintainer.

We recently added a new Side Effects Approaches page to the Redux documentation that should give a lot of information on all of this, but I'll try to write something short here too, as this question gets a lot of exposure.

In 2022 we added the Listener Middleware to the official Redux Toolkit for "reactive Redux logic". It can do most things that sagas can (the exception being channels) without requiring generator syntax and with better TypeScript support.
That doesn't mean that you should write everything with the listener middleware, though - we recommend always going for thunks first where possible and using the listener middleware in addition where thunks cannot do what you want to do.

Generally, our stance as of 2023 is that you should only use sagas if you have a particular need that cannot be met by other middleware. (Essentially: if you need channels.)

Our recommendation is:

Data Fetching

  • Use RTK Query as the default approach for data fetching and caching
  • If RTKQ doesn't fully fit for some reason, use createAsyncThunk
  • Only fall back to handwritten thunks if nothing else works
  • Don't use sagas or observables for data fetching!

Reacting to Actions / State Changes, Async Workflows

  • Use RTK listeners as the default for responding to store updates and writing long-running async workflows
  • Only use sagas / observables if listeners don't solve your use case well enough

Logic with State Access

  • Use thunks for complex sync and moderate async logic, including access to getState and dispatching multiple actions
Mcgrew answered 8/1, 2023 at 10:11 Comment(0)
T
1

One quick note. Generators are cancellable, async/await — not. So for an example from the question, it does not really make sense of what to pick. But for more complicated flows sometimes there is no better solution than using generators.

So, another idea could be is to use generators with redux-thunk, but for me, it seems like trying to invent a bicycle with square wheels.

And of course, generators are easier to test.

True answered 14/6, 2018 at 22:11 Comment(0)
C
0

Here's a project that combines the best parts (pros) of both redux-saga and redux-thunk: you can handle all side-effects on sagas while getting a promise by dispatching the corresponding action: https://github.com/diegohaz/redux-saga-thunk

class MyComponent extends React.Component {
  componentWillMount() {
    // `doSomething` dispatches an action which is handled by some saga
    this.props.doSomething().then((detail) => {
      console.log('Yaay!', detail)
    }).catch((error) => {
      console.log('Oops!', error)
    })
  }
}
Clothesline answered 23/5, 2017 at 3:39 Comment(4)
using then() inside a React component is against the paradigm. You should handle the changed state in componentDidUpdate rather than waiting for a promise to be resolved.Catena
@Maxincredible52 It isn't true for Server Side Rendering.Clothesline
In my experience, Max's point is still true for server side rendering. This should probably be handled somewhere in the routing layer.Arbitration
@Maxincredible52 why is it against the paradigm, where have you read that? I usually do similar to @Diego Haz but do it in componentDidMount (as per React docs, network calls should preferable be done there) so we have componentDidlMount() { this.props.doSomething().then((detail) => { this.setState({isReady: true})} }Spinose
E
0

I have recently joined a project that makes heavy use of redux-saga, and so was also interested in finding out more about the benefits of the saga approach.

TBH, I am still looking. Having read this post and many like it, the 'pros' are elusive. The above answers seem to sum it up as:

  1. testability (ignoring actual API calls),
  2. lots of helper functions,
  3. familiarity for developers who are used to server-side coding.

Many other claims seem optimistic, misleading or simply false! I've seen many unjustified claims that "thunks cannot do X" for example. But thunks are functions. If a function cannot do X, then javascript cannot do X. So sagas cannot do X either.

For me, the CONS are:

  • confounding of concerns by using generator functions. Generators in JS return custom iterators. That is all. They do not have any special ability to handle async calls or to be cancellable. Any loop can have a break-out condition, any function can handle async requests, and any code can make use of a custom iterator. When people say thing like: generators have control when to listen for some action or generators are cancellable, but async calls are not then it creates confusion by implying that these qualities are inherent in - or even unique to - generator functions.
  • unclear use-cases: AFAIK the SAGA pattern is for handling concurrent transaction issues across services. Given that browsers are single-threaded, it is hard to see how concurrency presents a problem that Promise methods can't handle. BTW: it is also hard to see why that class of problem should ever be handled in the browser.
  • code traceability: By using redux middleware to turn dispatch into a kind of event-handling, Sagas dispatch actions that never reach the reducers, and so never get logged by Redux tools. While other libraries also do this, it is often unnecessarily complicated, given that browsers have event-handling built in. The advantage of the indirection is again elusive, when calling the saga directly would be more obvious.

If this post makes me seem frustrated with sagas, it is because I am frustrated with sagas. They seem like a great solution looking for a problem to solve. IMO.

Eversion answered 14/6, 2022 at 21:5 Comment(3)
"[Generator functions] do not have any special ability to handle async calls or to be cancellable." - they have nice syntax to compose async, cancellable functions thoughMerrymaking
tbh at this point in 2022, both redux and redux-saga can be replaced with better state management solutions. I wouldn't even consider it in a new project. Quick guess, was your project started sometime around 2016/17 during the great JS framework fatigue?Friseur
@Friseur Good guess! Bergi I think 'cancellable' may be a bit misleading as regards functions or async calls.Eversion
R
-4

An easier way is to use redux-auto.

from the documantasion

redux-auto fixed this asynchronous problem simply by allowing you to create an "action" function that returns a promise. To accompany your "default" function action logic.

  1. No need for other Redux async middleware. e.g. thunk, promise-middleware, saga
  2. Easily allows you to pass a promise into redux and have it managed for you
  3. Allows you to co-locate external service calls with where they will be transformed
  4. Naming the file "init.js" will call it once at app start. This is good for loading data from the server at start

The idea is to have each action in a specific file. co-locating the server call in the file with reducer functions for "pending", "fulfilled" and "rejected". This makes handling promises very easy.

It also automatically attaches a helper object(called "async") to the prototype of your state, allowing you to track in your UI, requested transitions.

Relate answered 24/6, 2017 at 13:25 Comment(2)
I made +1 even it's irrelevant answer because different solutions should be considered tooSelry
I think the -'s are there because he didn't disclose he is the author of the projectBruiser

© 2022 - 2024 — McMap. All rights reserved.