E~wee~ctor: writing tiny Effector from scratch #2 — Maps and Filters

yumauri

Victor Didenko

Posted on April 2, 2020

E~wee~ctor: writing tiny Effector from scratch #2 — Maps and Filters

Hi, all!

In the previous article we've made minimal implementation of our new E~wee~ctor library, which could run "counter" example from Effector website. But, honestly, this example is all it could do, nothing more. So, let's add some more features.

In this chapter I want to add maps and filters.


Steps

Last time we decided to use functions as steps. That was good and simple for the start, but unfortunately we can't go further with this approach. In some cases kernel needs to make different decisions depending on steps. Like filter functionality – in case filter function returns false, kernel should stop execution for the current graph branch.

So we need to introduce step types:

const step = type => fn => ({
  type,
  fn,
})

export const compute = step('compute')
Enter fullscreen mode Exit fullscreen mode

Function step creates step object, containing fields type and fn. Let's begin with single step compute and change our existing code.

// change `watch` node

export const watch = unit => fn => {
  const node = createNode({
-    seq: [fn],
+    seq: [compute(fn)],
  })
  unit.graphite.next.push(node)
}

// --8<--

// change `store` unit

  store.graphite = createNode({
-    seq: [value => (currentState = value)],
+    seq: [compute(value => (currentState = value))],
  })

  store.on = (event, fn) => {
    const node = createNode({
      next: [store.graphite],
-      seq: [value => fn(currentState, value)],
+      seq: [compute(value => fn(currentState, value))],
    })
    event.graphite.next.push(node)
    return store
  }
Enter fullscreen mode Exit fullscreen mode

We also need to change kernel with following requirements:

  1. Kernel should be able to do different actions depending on a step type
  2. For the filter functionality we should be able to stop execution of current branch

In the first version we've used .forEach to traverse through all node steps. But it is impossible to stop and quit .forEach, so we have to rewrite it with good old for cycle:

const exec = () => {
  while (queue.length) {
    let { node, value } = queue.shift()

    for (let i = 0; i < node.seq.length; i++) {
      const step = node.seq[i]
      switch (step.type) {
        case 'compute':
          value = step.fn(value)
          break
      }
    }

    node.next.forEach(node => queue.push({ node, value }))
  }
}
Enter fullscreen mode Exit fullscreen mode

Now our steps preparations are done, let's go with maps first.

Event.map

export const createEvent = () => {
  // --8<--

  event.map = fn => {
    const mapped = createEvent()
    const node = createNode({
      next: [mapped.graphite],
      seq: [compute(fn)],
    })
    event.graphite.next.push(node)
    return mapped
  }

  // --8<--
}
Enter fullscreen mode Exit fullscreen mode

.map method accepts map function. It creates new event unit, and ties two events, old and new one, with new auxiliary node map. And given map function is executed inside this auxiliary node, to modify data.

Event.map

Event.prepend

Prepend is kind of like reverse map – it prepends event with new event.

export const createEvent = () => {
  // --8<--

  event.prepend = fn => {
    const prepended = createEvent()
    const node = createNode({
      next: [event.graphite],
      seq: [compute(fn)],
    })
    prepended.graphite.next.push(node)
    return prepended
  }

  // --8<--
}
Enter fullscreen mode Exit fullscreen mode

.prepend method behaves almost exactly like .map, just in an opposite direction:

Event.prepend

Store.map

export const createStore = defaultState => {
  // --8<--

  store.map = fn => {
    const mapped = createStore(fn(currentState))
    const node = createNode({
      next: [mapped.graphite],
      seq: [compute(fn)],
    })
    store.graphite.next.push(node)
    return mapped
  }

  // --8<--
}
Enter fullscreen mode Exit fullscreen mode

.map method accepts map function. It creates new store unit, and ties two stores, old and new one, with new auxiliary node map. And given map function is executed inside this auxiliary node, to modify data.
Additionally, to compute new store initial state, this method calls map function once with current store state.

⚠️ It should be noted, that this implementation doesn't follow Effector API completely – map function doesn't receive mapped store state as a second argument. We will fix this in later chapters.

Store.map

Event.filter

Filter functionality is a bit different beast. This is the first place, where we need new step type:

export const filter = step('filter')
Enter fullscreen mode Exit fullscreen mode

We also need to teach our kernel to support this new step filter:

const exec = () => {
-  while (queue.length) {
+  cycle: while (queue.length) {
    let { node, value } = queue.shift()

    for (let i = 0; i < node.seq.length; i++) {
      const step = node.seq[i]
      switch (step.type) {
        case 'compute':
          value = step.fn(value)
          break
+        case 'filter':
+          if (!step.fn(value)) continue cycle
+          break
      }
    }

    node.next.forEach(node => queue.push({ node, value }))
  }
}
Enter fullscreen mode Exit fullscreen mode

If we meet a step with type filter, and its filter function returns falsy value – we just skip all other execution in this branch.
If you are unfamiliar with this strange syntax continue cycle – this is called label, you can read about it here.

Next let's add .filter method to event:

export const createEvent = () => {
  // --8<--

  event.filter = fn => {
    const filtered = createEvent()
    const node = createNode({
      next: [filtered.graphite],
      seq: [filter(fn)],
    })
    event.graphite.next.push(node)
    return filtered
  }

  // --8<--
}
Enter fullscreen mode Exit fullscreen mode

As you can see, it looks exactly like .map method, with only difference – instead of step compute we use step filter.

⚠️ This implementation doesn't follow Effector API also – due to historical reasons Effector's Event.filter accepts not function, but object {fn}.

Event.filter

Event.filterMap

export const createEvent = () => {
  // --8<--

  event.filterMap = fn => {
    const filtered = createEvent()
    const node = createNode({
      next: [filtered.graphite],
      seq: [compute(fn), filter(value => value !== undefined)],
    })
    event.graphite.next.push(node)
    return filtered
  }

  // --8<--
}
Enter fullscreen mode Exit fullscreen mode

.filterMap method is like .map and .filter combined together. This is the first place, where we've created auxiliary node filterMap, containing two steps – compute, to execute given function, and filter, to check, if we have undefined or not value.

Event.filterMap


And that's it for today!
You can see all this chapter changes in this commit.
I've also added automated testing, so we can be sure, that we will not break old functionality with new one.

Thank you for reading!
To be continued...

💖 💪 🙅 🚩
yumauri
Victor Didenko

Posted on April 2, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related