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

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

yumauri profile image Victor Didenko Updated on ・6 min read

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

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

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
}

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

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
}

So we will be able to watch events:

const event = createEvent()
event.watch(data => console.log(data))

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 = []

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

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

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
}

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

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

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

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

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
 

Awesome explanation 👍