loading...
Cover image for E~wee~ctor: writing tiny Effector from scratch #4 — Effect

E~wee~ctor: writing tiny Effector from scratch #4 — Effect

yumauri profile image Victor Didenko ・6 min read

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 a true 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:

Events

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.

With stores

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 the done/fail/doneData/failData events automatically

Here is one important thing left though, without which this code will not work properly:

  1. Steps are executed during the computation cycle inside the kernel
  2. We use function launch inside the step, while we are inside the computation cycle
  3. 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 by:

yumauri profile

Victor Didenko

@yumauri

Just an ordinary programmer :) My main passion is JS and its whole ecosystem.

Discussion

pic
Editor guide
 

Thank you for your work, that's a great article series! I've added it to awesome-effector.

 

Thank you :) I mean to continue, there are more things I want to cover, just haven't spare time recently...