Please use something else. See: #26
ReEffects for Effector ☄️
Like regular Effects, but better :)
- Supports different launch strategies: TAKE_FIRST, TAKE_LAST, TAKE_EVERY, QUEUE, RACE
- Handles promises cancellation
- Can handle logic cancellation
$ yarn add effector-reeffect
Or using npm
$ npm install --save effector-reeffect
In basic version you can use it like regular Effect:
import { createReEffect } from 'effector-reeffect'
// create ReEffect
const fetchUser = createReEffect({
handler: ({ id }) =>
fetch(`https://example.com/users/${id}`).then(res => res.json()),
})
Nothing special yet, created ReEffect has same properties, as usual Effect: Events done
, fail
, finally
; Store pending
; and methods use(handler)
, watch(watcher)
, prepend(fn)
. Check out documentation to learn more.
Magic begins when you call ReEffect more that once, while previous asynchronous operation is not finished yet 🧙♂️
import { createReEffect, TAKE_LAST } from 'effector-reeffect'
// create ReEffect
const fetchUser = createReEffect({
handler: ({ id }) =>
fetch(`https://example.com/users/${id}`).then(res => res.json()),
})
// call it once
fetchUser({ id: 1 }, TAKE_LAST)
// and somewhere in a galaxy far, far away
fetchUser({ id: 1 }, TAKE_LAST)
You see the new TAKE_LAST
argument - this is called strategy, and TAKE_LAST one ensures, that only latest call will trigger done
(or fail
) event. Each call still remain separate Promise, so you can await it, but first one in the example above will be rejected with CancelledError
instance (you can import this class from package and check error instanceof CancelledError
).
This is default strategy, if you will not specify any other.
Second effect call will launch second asynchronous operation. In contrast with usual Effect, ReEffect will trigger .done
(or .fail
) event only for latest operation, and .pending
store will contain true
for a whole time of all operations (in other words, if there is at least single pending operation - .pending
will hold true
).
Second effect call will be immediately rejected with CancelledError
(handler will not be executed at all).
Second effect call will reject all currently pending operations (if any) with CancelledError
.
Second effect will not be launched until all other pending effects are finished.
First finished effect will win the race and cancel all other pending effects with CancelledError
.
This strategy is a bit different, then first four. You can call them "→IN strategies", while RACE is "OUT→ strategy".
ReEffect checks →IN strategy in the moment effect was launched. Effect, launched with TAKE_LAST strategy, will cancel all currently pending effects, regardless of their strategies. Effect, launched with QUEUE strategy, will be placed in queue to wait all currently pending effects, regardless of their strategies. And so on.
OUT→ strategy is checked, when effect is fulfilled (but not cancelled). Effect with RACE strategy, upon finished, will cancel all other pending effects, regardless of their strategies.
It should be noted, that due to asynchronous cancellation, cancelled
events for loser effects will happen after main done
/fail
event, and after pending
is set to false
.
ReEffect has few new properties:
.cancelled
: Event triggered when any handler is rejected withCancelledError
orLimitExceededError
(this will be described later)..cancel
: Event you can trigger to manually cancel all currently pending operations - each cancelled operation will trigger.cancelled
event.
createReEffect
function accepts same arguments as usual Effect, with few possible additions in config:
strategy
: this strategy will be considered as default, instead ofTAKE_EVERY
. Possible values:TAKE_EVERY
,TAKE_FIRST
,TAKE_LAST
,QUEUE
orRACE
.feedback
: iftrue
— putsstrategy
field intodone
,fail
orcancelled
event's payload. Withfalse
by default ReEffect behaves just like usual Effect, with exactly the same results.limit
: maximum count of simultaneously running operation, by defaultInfinity
. If new effect call will exceed this value, call will be immediately rejected withLimitExceededError
error.timeout
: timeout for effect execution, in milliseconds. If timeout is exceeded — effect will be rejected withTimeoutError
.
const fetchUser = createReEffect('fetchUser', {
handler: ({ id }) =>
fetch(`https://example.com/users/${id}`).then(res => res.json()),
strategy: TAKE_LAST,
feedback: true,
limit: 3,
timeout: 5000,
})
ReEffect, created with createReEffect
function, behave like usual Effect, with one difference: in addition to effect's payload
you can specify strategy as a second argument. This strategy will override default strategy for this effect (but will not replace default strategy).
You can also specify config object, with strategy
or/and timeout
.
// this are equivalent calls
fetchUser({ id: 2 }, TAKE_EVERY)
fetchUser({ id: 2 }, { strategy: TAKE_EVERY })
fetchUser({ params: { id: 2 }, strategy: TAKE_EVERY })
// or if your effect doesn't have payload
fetchAllUsers(undefined, RACE)
fetchAllUsers(undefined, { strategy: RACE })
fetchAllUsers({ strategy: RACE })
// with timeout
fetchUser({ id: 42 }, { timeout: 5000 })
fetchUser({ params: { id: 42 }, strategy: TAKE_EVERY, timeout: 5000 })
ReEffect will handle Promises cancellation for you (so handler promise result will be ignored), but it cannot cancel logic by itself! There are quite an amount of possible asynchronous operations, and each one could be cancelled differently (and some could not be cancelled at all).
But bright side of it is that you can tell ReEffect, how to cancel your logic ☀️
To do this, handler
accepts onCancel
callback as a second argument, and you can specify, what actually to do on cancel.
Let me show an example:
import { createReEffect, TAKE_LAST } from 'effector-reeffect'
const reeffect = createReEffect({ strategy: TAKE_LAST })
reeffect.watch(_ => console.log('reeffect called:', _))
reeffect.done.watch(_ => console.log('reeffect done:', _))
reeffect.fail.watch(_ => console.log('reeffect fail:', _))
reeffect.cancelled.watch(_ => console.log('reeffect cancelled:', _))
reeffect.use(
params =>
new Promise(resolve => {
setTimeout(() => {
console.log(`-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: ${params}`)
resolve('done')
}, 1000)
})
)
reeffect(1)
reeffect(2)
If you will run code above, you will get
reeffect called: 1
reeffect called: 2
reeffect cancelled: { params: 1,
error: Error: Cancelled due to "TAKE_LAST", new effect was added }
-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: 1
-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: 2
reeffect done: { params: 2, result: 'done' }
As you can see, first effect call was rejected and cancelled, but timeout itself was not cancelled, and printed message.
Let's change code above:
import { createReEffect, TAKE_LAST } from 'effector-reeffect'
const reeffect = createReEffect({ strategy: TAKE_LAST })
reeffect.watch(_ => console.log('reeffect called:', _))
reeffect.done.watch(_ => console.log('reeffect done:', _))
reeffect.fail.watch(_ => console.log('reeffect fail:', _))
reeffect.cancelled.watch(_ => console.log('reeffect cancelled:', _))
reeffect.use((params, onCancel) => {
let timeout
onCancel(() => clearTimeout(timeout))
return new Promise(resolve => {
timeout = setTimeout(() => {
console.log(`-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: ${params}`)
resolve('done')
}, 1000)
})
})
reeffect(1)
reeffect(2)
Now ReEffect know, how to cancel your Promise's logic, and will do it while cancelling operation:
reeffect called: 1
reeffect called: 2
reeffect cancelled: { params: 1,
error: Error: Cancelled due to "TAKE_LAST", new effect was added }
-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: 2
reeffect done: { params: 2, result: 'done' }
This could be done with any asynchronous operation, which supports cancellation or abortion.
Axios supports cancellation via cancel token:
reeffect.use(({ id }, onCancel) => {
const source = CancelToken.source()
onCancel(() => source.cancel())
return axios.get(`https://example.com/users/${id}`, {
cancelToken: source.token,
})
})
Fetch API supports cancellation via AbortController (read more):
reeffect.use(({ id }, onCancel) => {
const controller = new AbortController()
onCancel(() => controller.abort())
return fetch(`https://example.com/users/${id}`, {
signal: controller.signal,
})
})
Ky is built on top of Fetch API, and supports cancellation via AbortController as well:
reeffect.use(({ id }, onCancel) => {
const controller = new AbortController()
onCancel(() => controller.abort())
return ky(`https://example.com/users/${id}`, {
signal: controller.signal,
}).json()
})
Note: request has been deprecated, you probably should not use it.
Request HTTP client supports .abort()
method:
reeffect.use(({ id }, onCancel) => {
let r
onCancel(() => r.abort())
return new Promise((resolve, reject) => {
r = request(`https://example.com/users/${id}`, (err, resp, body) =>
err ? reject(err) : resolve(body)
)
})
})
If you happen to use good old XMLHttpRequest
, I will not blame you (but others definitely will). Good to know it supports cancellation too, via .abort()
method:
reeffect.use(({ id }, onCancel) => {
let xhr
onCancel(() => xhr.abort())
return new Promise(function (resolve, reject) {
xhr = new XMLHttpRequest()
xhr.open('GET', `https://example.com/users/${id}`)
xhr.onload = function () {
if (this.status >= 200 && this.status < 300) {
resolve(xhr.response)
} else {
reject({
status: this.status,
statusText: xhr.statusText,
})
}
}
xhr.onerror = function () {
reject({
status: this.status,
statusText: xhr.statusText,
})
}
xhr.send()
})
})
Yes! Alongside with createReEffect
ReEffect package exports factory createReEffectFactory
, you can use it to wrap createEffect
from domain:
import { createDomain } from 'effector'
import { createReEffectFactory } from 'effector-reeffect'
const domain = createDomain()
const createReEffect = createReEffectFactory(domain.createEffect)
const fetchUser = createReEffect(/* ... */)
// -> fetchUser will belong to domain
Yes, with Effector version 22.
I didn't try it, but most probably no :(
First of all, after attach
you will get regular Effect, not ReEffect, and secondarily, looks like attach
implementation replaces req
parameter, which highly likely will break ReEffect functionality.
There is issue #8 to track this case.
If you want just attach store to your ReEffect, you can try technique, called "CoEffect":
/**
* Creates CoEffect - ReEffect, attached to the store value
*/
function createCoEffect({ store, handler, ...config }) {
const fx = createReEffect(config)
// save original `use`
const use = fx.use
// replace original `use`, to be able to change handler on CoEffect
// you can omit this, if you don't intend to replace CoEffect handler
fx.use = fn => (handler = fn)
// on each store change replace handler for ReEffect,
// so it will be called with actual store value every time
store.watch(value => {
use((payload, onCancel) => handler(payload, value, onCancel))
})
return fx
}