Cancel a saga when an action is dispatched with redux-saga
Asked Answered
K

3

25

I start a timer for a stopwatch React component when a START action is dispatched:

import 'babel-polyfill'
import { call, put } from 'redux-saga/effects'
import { delay, takeEvery, takeLatest } from 'redux-saga'
import { tick, START, TICK, STOP } from './actions'

const ONE_SECOND = 1000

export function * timerTickWorkerSaga (getState) {
  yield call(delay, ONE_SECOND)
  yield put(tick())
}

export default function * timerTickSaga () {
  yield* takeEvery([START, TICK], timerTickWorkerSaga)
  yield* takeLatest(STOP, cancel(timerTickWorkerSaga))
}
/*
  The saga should start when either a START or a TICK is dispatched
  The saga should stop running when a stop is dispatched
*/

I have trouble stopping the saga when the STOP action is dispatched from my component. I have tried using cancel and cancelled effects from within my worker saga:

if(yield(take(STOP)) {
  yield cancel(timerTickWorkerSaga)
}

as well as the approach in the first code block where I try and stop the saga from the watching service.

Kayne answered 7/6, 2016 at 21:20 Comment(0)
T
15

Looks like a few things are going on here:

  1. The cancel side effect takes a Task object as its argument. What you're passing into it in the code above is just the GeneratorFunction that creates the saga/Generator object. For a great intro to generators and how they work, check out this article.
  2. You're using yield* before the takeEvery and takeLatest generators. Using yield* will spread the whole sequence. So you can think of it like this: that it's filling in the line

    yield* takeEvery([START, TICK], timerTickWorkerSaga)

    with

    while (true) {
        const action = yield take([START, TICK])
        yield fork(timeTickWorkerSaga, action)
    }
    

    And I don't think this is what you're going for, because I believe this will end up blocking the second line of your timerTickSaga. Instead you probably want:

    yield fork(takeEvery, [START, TICK], timerTickWorkerSaga)
    

    This forks off the takeEvery effect so it doesn't block the next line.

  3. The second argument you're passing into takeLatest is just an object - a CANCEL effect object. The second argument to takeLatest should actually be a GeneratorFunction, which will be run when an action matching the STOP pattern is dispatched to the Redux store. So that should really be a saga function. You want this to cancel the fork(takeEvery, [START, TICK], timerTickWorkerSaga) task so that future START and TICK actions will not cause the timerTickWorkerSaga to run. You can achieve this by having the saga run a CANCEL effect with the Task object that resulted from the fork(takeEvery... effect. We can the Task object as an additional argument to the takeLatest saga. So we end up with something along the lines of:

    export default function * timerTickSaga () {
        const workerTask = yield fork(takeEvery, [START, TICK], timerTickWorkerSaga)
        yield fork(takeLatest, STOP, cancelWorkerSaga, workerTask)
    }
    
    function* cancelWorkerSaga (task) {
        yield cancel(task)
    }
    

For additional reference check out the task cancellation example in the redux-saga docs. If you look in the main saga there, you'll see how the fork effect yields a Task object/descriptor that is used further down when yielding the cancel effect.

Trim answered 8/6, 2016 at 5:7 Comment(1)
As you might have suspected, I have been neglecting ES6 generators, thanks you for the answer and the useful resources.Kayne
C
19

Redux-Saga has a method for this now, it's called race race. It will run 2 tasks, but when one finishes, it will automatically cancel the other.

  • https://redux-saga.js.org/docs/advanced/RacingEffects.html

  • watchStartTickBackgroundSaga is always running

  • Every time there's a start or tick, start a race between timerTickWorkerSaga and listening for the next STOP action.
  • When one of those tasks finishes, the other task is cancelled this is the behavior of race.
  • The names "task" and "cancel" inside of race do not matter, they just help readability of the code

export function* watchStartTickBackgroundSaga() {
  yield takeEvery([START, TICK], function* (...args) {
    yield race({
      task: call(timerTickWorkerSaga, ...args),
      cancel: take(STOP)
    })
  })
}
Carabao answered 21/8, 2017 at 22:6 Comment(5)
Hi Cory, Your's the only that solution worked for me in my case. I have two questions: 1) When I use the solution provided by @Marc, the saga gets cancelled, but it just won't listen again to the same action. Why is that? 2) In your solution, what is the use of (...args) parameter? It works even without providing those?Purple
...args will save all arguments of the function* and save them as an array in a variable called args. Using them in call(timerTickWorkerSaga, ...args) will then pass all of those arguments into timerTickWorkerSaga. In redux-saga the argument would be the action that was caught in takeEvery - See jsfiddle.net/0pg1zy45Carabao
Marc's solution is not listening again, because you're actually cancelling the takeEvery effect. My solution is nested within the takeEvery, and the actual timerTickWorkerSaga task is cancelled rather than the takeEvery. So my solution will start a race on every START/TICK and cancel on STOP, but Marc's will run takeEvery START/TICK and is cancelled on a STOP.Carabao
@CoryDanielson Man...that's brilliant. Thanks for taking the time out and explaining things. That fiddle helped me a lot in understanding that args.Purple
For forked tasks that's the best option for sure.Whiten
T
15

Looks like a few things are going on here:

  1. The cancel side effect takes a Task object as its argument. What you're passing into it in the code above is just the GeneratorFunction that creates the saga/Generator object. For a great intro to generators and how they work, check out this article.
  2. You're using yield* before the takeEvery and takeLatest generators. Using yield* will spread the whole sequence. So you can think of it like this: that it's filling in the line

    yield* takeEvery([START, TICK], timerTickWorkerSaga)

    with

    while (true) {
        const action = yield take([START, TICK])
        yield fork(timeTickWorkerSaga, action)
    }
    

    And I don't think this is what you're going for, because I believe this will end up blocking the second line of your timerTickSaga. Instead you probably want:

    yield fork(takeEvery, [START, TICK], timerTickWorkerSaga)
    

    This forks off the takeEvery effect so it doesn't block the next line.

  3. The second argument you're passing into takeLatest is just an object - a CANCEL effect object. The second argument to takeLatest should actually be a GeneratorFunction, which will be run when an action matching the STOP pattern is dispatched to the Redux store. So that should really be a saga function. You want this to cancel the fork(takeEvery, [START, TICK], timerTickWorkerSaga) task so that future START and TICK actions will not cause the timerTickWorkerSaga to run. You can achieve this by having the saga run a CANCEL effect with the Task object that resulted from the fork(takeEvery... effect. We can the Task object as an additional argument to the takeLatest saga. So we end up with something along the lines of:

    export default function * timerTickSaga () {
        const workerTask = yield fork(takeEvery, [START, TICK], timerTickWorkerSaga)
        yield fork(takeLatest, STOP, cancelWorkerSaga, workerTask)
    }
    
    function* cancelWorkerSaga (task) {
        yield cancel(task)
    }
    

For additional reference check out the task cancellation example in the redux-saga docs. If you look in the main saga there, you'll see how the fork effect yields a Task object/descriptor that is used further down when yielding the cancel effect.

Trim answered 8/6, 2016 at 5:7 Comment(1)
As you might have suspected, I have been neglecting ES6 generators, thanks you for the answer and the useful resources.Kayne
P
9

The answer from rayd is very correct but a bit superfluous in the way that takeEvery and takeLatest internally are doing a fork. You can see the explanation here:

So the code should be:

export default function* timerTickSaga() {
    const workerTask = yield takeEvery([START, TICK], timerTickWorkerSaga);
    yield takeLatest(STOP, cancelWorkerSaga, workerTask);
}

function* cancelWorkerSaga(task) {
    yield cancel(task);
}
Phila answered 30/10, 2016 at 16:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.