E~wee~ctor: writing tiny Effector from scratch #1 — Counter
Victor Didenko
Posted on April 1, 2020
Hi, all!
I really like "Implement something from scratch" type of articles, for example:
- Lost with Redux and sagas? Implement them yourself!
- Building Redux from scratch
- Build your own React – this one is particularly awesome
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:
And here is what Effector creates, structural graph of nodes:
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.
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:
- While
queue
is not empty, take element from queue - Execute each step from node, with initial value from queue element
- Put each node from
next
array to the queue, with new value - 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).
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 April 1, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.