loading...
Cover image for E~wee~ctor: writing tiny Effector from scratch #2 — Maps and Filters

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

yumauri profile image Victor Didenko Updated on ・4 min read

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')

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
  }

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 }))
  }
}

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<--
}

.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<--
}

.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<--
}

.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')

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 }))
  }
}

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<--
}

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<--
}

.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...

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
 

Poooh...
It is was hard for my mind(
Thnks for breaking up the information in less than 5 minutes!

 

Chapter #4 is 6 min read, sorry 😅