E~wee~ctor: writing tiny Effector from scratch #1 — Counter

yumauri

Victor Didenko

Posted on April 1, 2020

E~wee~ctor: writing tiny Effector from scratch #1 — Counter

Hi, all!

I really like "Implement something from scratch" type of articles, for example:

It is a good way to learn how things work, and to unveil magic behind the black box of a library.

I plan to write a small Effector ☄️-like library called E~wee~ctor. This will be an educational-only purpose project.

  • Name "eweector" is derived from "effector" and "wee", meaning "little, small, tiny"
  • Second meaning of "wee" is also fit
  • Also my name is Victor and I like how "eweector" sounds :)

Good and simple place to start is a "counter" example from Effector website:

import {createStore, createEvent} from 'effector'

const add = createEvent()
const sub = createEvent()
const reset = createEvent()

const counter = createStore(0)
  .on(add, (count, n) => count + n)
  .on(sub, (count, n) => count - n)
  .reset(reset)

counter.watch(n => console.log('counter:', n))
// counter: 0
add.watch(n => console.log('add', n))
sub.watch(n => console.log('subtract', n))
reset.watch(() => console.log('reset counter'))

add(5)
// add 5
// counter: 5
sub(1)
// subtract 1
// counter: 4
reset()
// reset counter
// counter: 0
Enter fullscreen mode Exit fullscreen mode

Thus we need to implement two main entities – event and store – and some of their methods.

I will not dive deeply into Effector right now, if you want to do it, check this article out. But here are some highlights from Effector's architecture:

  • Effector internally creates a graph of nodes
  • Nodes has (obviously) links to next nodes = edges of the graph
  • Any node can contain sequence of steps to run
  • A step can modify input data somehow, or stop branch computation, or launch some side-effect function
  • Upon some event Effector's kernel passes input data to a node and starts executing steps, using the breadth-first search algorithm
  • Any high-level Effector's unit (Event, Store, Effect, Domain) is a some object, attached to a node or bunch of nodes.

Here is the logical relationship graph for the code above:

Relationship Graph

And here is what Effector creates, structural graph of nodes:

Structural Graph

Node

Let's start with the core thing of a graph – a node. A node should contain links to the next nodes – edges of a graph, and sequence of steps – where we will describe logic.

export const createNode = ({ next = [], seq = [] } = {}) => ({
  next,
  seq,
})
Enter fullscreen mode Exit fullscreen mode

createNode factory function is very simple, it just creates a node object and nothing else. We could even go without it, and describe nodes in-place, like plain objects. But factory function gives us two benefits:

  • Each and every node it returns has same structure, so we will not miss some fields, defining them manually
  • ES6 syntax has very handy default parameters and destructuring assignment, and they can take burden of assigning default field values for node

Steps

Next thing we need is steps. There are six types of steps in Effector, but we will start from a single one. And we will not even create factory for it :) Let's just use function as a step. So out nodes will contain a sequence of functions to execute.

Event

Event is the simplest Effector unit. It is a function, attached to a graph node. The only purpose of an event as a high-level entity is to place input data into the node and to start computation cycle.

export const createEvent = () => {
  const event = payload => launch(event, payload)
  event.graphite = createNode()
  return event
}
Enter fullscreen mode Exit fullscreen mode

createEvent factory creates function and attaches graph node into its graphite field. Created event-function accepts input data and executes function launch. This launch function will start a computation cycle, and we will describe it later, when we write a kernel.

As you can see, a node, attached to an event, doesn't have any next nodes, nor steps.

Watch

Before writing a kernel, let's implement watch functionality.

Watch is an auxiliary node, which runs side-effect. In case of our counter example this side-effect is console.log.

export const watch = unit => fn => {
  const node = createNode({
    seq: [fn],
  })
  unit.graphite.next.push(node)
}
Enter fullscreen mode Exit fullscreen mode

watch function accepts two arguments (in a functional programming meaning) – unit to attach watch node, and function to execute.

As we decided to use simple functions as steps (for now) – we will just use given side-effect function as a step for watch node.

And after creating watch node, we put this node to the next array for the given unit.

Watch

And now let's add .watch method to our event:

export const createEvent = () => {
  const event = payload => launch(event, payload)
  event.graphite = createNode()
+  event.watch = watch(event)
  return event
}
Enter fullscreen mode Exit fullscreen mode

So we will be able to watch events:

const event = createEvent()
event.watch(data => console.log(data))
Enter fullscreen mode Exit fullscreen mode

Kernel

And here we go :) The kernel. It is not that frightening as it sounds, really.
There are two main parts of the kernel: queues and computation cycle.

Effector utilises five queues. We will start with single one:

// contains objects { node, value }
const queue = []
Enter fullscreen mode Exit fullscreen mode

Computation cycle traverses graph and executes each step from each node:

const exec = () => {
  while (queue.length) {
    let { node, value } = queue.shift()
    node.seq.forEach(step => (value = step(value)))
    node.next.forEach(node => queue.push({ node, value }))
  }
}
Enter fullscreen mode Exit fullscreen mode

In simple words function exec can be described as following steps:

  1. While queue is not empty, take element from queue
  2. Execute each step from node, with initial value from queue element
  3. Put each node from next array to the queue, with new value
  4. Go to 1.

One more thing – we need function launch for our events:

export const launch = (unit, value) => {
  queue.push({ node: unit.graphite, value })
  exec()
}
Enter fullscreen mode Exit fullscreen mode

launch function just puts node and value into the queue, and starts computation cycle. Thats it.

Store

And last, but not least – a store.

export const createStore = defaultState => {
  let currentState = defaultState
  const store = {}

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

  return store
}
Enter fullscreen mode Exit fullscreen mode

createStore factory creates object, and attaches graph node into its graphite field, just like with an event. But store node has one step – it saves input data into an enclosured variable currentState.

We also need to implement few store's methods, like .on, .reset and .watch. Let's start with last one:

  store.watch = fn => {
    fn(currentState)
    return watch(store)(fn)
  }
Enter fullscreen mode Exit fullscreen mode

.watch method for store is a bit different, than for an event – first time it is called it executes given function with current state, and then creates watch node.

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

.on method accepts an event (or any unit) and reducer function. Like .watch method it creates a new on node, with one step, where reducer is called. And places this new node before store node in the graph, so new value from reducer will be saved in the store node. Also it puts this new node to the next array for the given event (or unit).

On

  store.reset = event => store.on(event, () => defaultState)
Enter fullscreen mode Exit fullscreen mode

.reset method is just shortcut to set initial state.


And we've done our first step in this journey. If we combine all these pieces together, we will get minimal working "eweector", which could run counter example. And what is more important – it follows Effector's architecture!

In next chapters we will grow up our infant. I'll try to cover all Effector's API in reasonable limits, so stay tuned ;)

I've created project on GitHub to help you follow the source code.
All code, described in this chapter, is committed in this commit.

Thank you for reading!

💖 💪 🙅 🚩
yumauri
Victor Didenko

Posted on April 1, 2020

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

Sign up to receive the latest update from our blog.

Related