E~wee~ctor: writing tiny Effector from scratch #4 — Effect
Victor Didenko
Posted on April 10, 2020
Hey, all!
Upon this moment we've implemented two main Effector's entities – an event
and a store
– and were avoiding an effect
. So, let's accept this challenge!
First of all, according to documentation, an effect is a container for async function. It is used for side-effects, like interaction with a server, or any timeouts and intervals. Actually, you can use any function inside an effect, it doesn't need to be asynchronous in general, but it is so in most cases. But it is important in the Effector ecosystem to use effects for side-effects.
An effect is a complex entity, and contains of a dozen nodes and other entities:
-
done
– is an event triggered when handler is resolved -
fail
– is an event triggered when handler is rejected or throws error -
finally
– is an event triggered when handler is resolved, rejected or throws error -
doneData
– is an event triggered with result of effect execution -
failData
– is an event triggered with error thrown by effect -
pending
– is a boolean store containing atrue
value until the effect is resolved or rejected -
inFlight
– is a store showing how many effect calls aren't settled yet
Here is what we will start with:
export const createEffect = ({ handler }) => {
const effect = payload => launch(effect, payload)
effect.graphite = createNode()
effect.watch = watch(effect)
effect.prepend = fn => {
const prepended = createEvent()
createNode({
from: prepended,
seq: [compute(fn)],
to: effect,
})
return prepended
}
// TODO
effect.kind = 'effect'
return effect
}
This stub looks exactly like part of an event. In fact, Effector uses an event under the hood as a base for an effect, but we will create it from scratch for simplicity.
The only differences from an event here yet is that createEffect
function accepts an object with the handler
field. And effect.kind
is "effect"
, so we can distinguish effects from other entities.
Now let's add a method use
to change handler
:
effect.use = fn => (handler = fn)
effect.use.getCurrent = () => handler
And create bunch of child events for the effect:
const anyway = createEvent()
const done = anyway.filterMap(({ status, ...rest }) => {
if (status === 'done') return rest
})
const fail = anyway.filterMap(({ status, ...rest }) => {
if (status === 'fail') return rest
})
const doneData = done.map(({ result }) => result)
const failData = fail.map(({ error }) => error)
effect.finally = anyway
effect.done = done
effect.fail = fail
effect.doneData = doneData
effect.failData = failData
Hereby we've created all the events for our effect. Base event is effect.finally
(finally
is a reserved word, so we can't name a variable like this, so we use name anyway
for it). All other events are derived from this base event:
Looking at the code above I feel urgent desire to extract common logic to helper functions:
const status = name => ({ status, ...rest }) =>
status === name ? rest : undefined
const field = name => object => object[name]
// --8<--
const anyway = createEvent()
const done = anyway.filterMap(status('done'))
const fail = anyway.filterMap(status('fail'))
const doneData = done.map(field('result'))
const failData = fail.map(field('error'))
Now let's add stores pending
and inFlight
:
effect.inFlight = createStore(0)
.on(effect, x => x + 1)
.on(anyway, x => x - 1)
effect.pending = effect.inFlight.map(amount => amount > 0)
That is simple: store inFlight
subscribes to the effect itself and its finally
event. And boolean store pending
is true
when inFlight
has positive value.
And now we've come close to the main part of the effect – running our side-effect function handler
. We will just add a single step to our main effect's node, where the handler
will be launched:
effect.graphite.seq.push(
compute(params => {
try {
const promise = handler(params)
if (promise instanceof Promise) {
promise
.then(result => launch(anyway, { status: 'done', params, result }))
.catch(error => launch(anyway, { status: 'fail', params, error }))
} else {
launch(anyway, { status: 'done', params, result: promise })
}
} catch (error) {
launch(anyway, { status: 'fail', params, error })
}
return params
})
)
- we run the handler inside try-catch block, so if we get a synchronous exception – it will be caught
- if
handler
returns a Promise, we wait for it to settle - if
handler
returns not a Promise, we just use returned value as a result - in any case we launch result (either successful or failed) to the
finally
event, so it will be processed to thedone
/fail
/doneData
/failData
events automatically
Here is one important thing left though, without which this code will not work properly:
- Steps are executed during the computation cycle inside the kernel
- We use function
launch
inside the step, while we are inside the computation cycle - Function
launch
starts the computation cycle
Do you see the problem?
We have one single queue to process, and secondary run of the computation cycle inside the already running computation cycle will mess it all around! We don't want this, so let's add a guard to protect from this situation in our kernel:
let running = false
const exec = () => {
if (running) return
running = true
// --8<--
running = false
}
After this fix step inside effect's node will work perfectly.
But there is one more thing to fix: effect should return a Promise, so it can be awaited. For now our effect's function, which is tied to the node, is exactly the same as function for an event – it just launches given payload to the node (and returns nothing):
const effect = payload => launch(effect, payload)
But it should return a Promise, as was said. And we should be able to somehow resolve or reject this Promise from inside step.
And here we need so called Deferred object. This is a common pattern to have a Promise, which can be settled from outside. Here is a nice explanation of this approach, read this, if you didn't meet deferred objects yet.
A Promise represents a value that is not yet known.
A Deferred represents work that is not yet finished.
export const defer = () => {
const deferred = {}
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve
deferred.reject = reject
})
// we need this to avoid 'unhandled exception' warning
deferred.promise.catch(() => {})
return deferred
}
defer
function creates a deferred object. Now we can use this deferred object to return a Promise from an effect, and settle it from inside a step. But we also need to consider a situation, when the effect is called not directly, but from the other graph node, for example like forward({ from: event, to: effect })
. In that case we don't need to create useless Deferred object.
Let's use helper class to distinguish direct and indirect call cases. We could use simple object, but we can't be sure, that one day effect will not receive exactly this shape of an object as a payload. So we use internal class and instanceof
check, to be sure, that only our code can create class instance.
⚠️ Effector checks this differently, using call stack, provided by the kernel, but we will go simple way :)
function Payload(params, resolve, reject) {
this.params = params
this.resolve = resolve
this.reject = reject
}
Now we need to change main function, and then add one more step to check use case:
const effect = payload => {
const deferred = defer()
launch(effect, new Payload(payload, deferred.resolve, deferred.reject))
return deferred.promise
}
// --8<--
compute(data =>
data instanceof Payload
? data // we get this data directly
: new Payload( // we get this data indirectly through graph
data,
() => {}, // dumb resolve function
() => {} // dumb reject function
)
)
After this step next one will get a Payload
instance in both cases, either effect was called directly or indirectly. We need to change our existing step to handle this new Payload
instance instead of plain params.
// helper function to handle successful case
const onDone = (event, params, resolve) => result => {
launch(event, { status: 'done', params, result })
resolve(result)
}
// helper function to handle failed case
const onFail = (event, params, reject) => error => {
launch(event, { status: 'fail', params, error })
reject(error)
}
// --8<--
compute(({ params, resolve, reject }) => {
const handleDone = onDone(anyway, params, resolve)
const handleFail = onFail(anyway, params, reject)
try {
const promise = handler(params)
if (promise instanceof Promise) {
promise.then(handleDone).catch(handleFail)
} else {
handleDone(promise)
}
} catch (error) {
handleFail(error)
}
return params
})
And that's it, our effect shines and ready!
I am slightly worried, that reading this chapter could be difficult, and someone could not glue code pieces together. As always, you can find whole changes in this commit, so feel free to check it out!
Thank you for reading!
To be continued...
Posted on April 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.