loading...
Cover image for E~wee~ctor: writing tiny Effector from scratch #3 — Simple API methods

E~wee~ctor: writing tiny Effector from scratch #3 — Simple API methods

yumauri profile image Victor Didenko ・4 min read

Hi, folks!

In this article I want to implement some simple Effector API functions. But before we start, let's improve one thing.

You might have noticed, that we create auxiliary node and add it to the next array of other node quite often, like this:

  event.map = fn => {
    const mapped = createEvent()

    // create new node
    const node = createNode({
      next: [mapped.graphite],
      seq: [compute(fn)],
    })

    // add it to the event's next nodes
    event.graphite.next.push(node)

    return mapped
  }

Let's improve createNode function, so it will do it for us:

export const getGraph = unit => unit.graphite || unit

const arrify = units =>
  [units]
    .flat()          // flatten array
    .filter(Boolean) // filter out undefined values
    .map(getGraph)   // get graph nodes

export const createNode = ({ from, seq = [], to } = {}) => {
  const node = {
    next: arrify(to),
    seq,
  }
  arrify(from).forEach(n => n.next.push(node))
  return node
}

I've renamed parameter next to to, and added new parameter from, accepting previous nodes.
getGraph helper function gives us ability to pass both units and nodes, without taking care of field .graphite. Also, with arrify helper function we can pass single unit or array of units to the from and to parameters.

Now any createNode call should be more readable:

from node → sequence of steps → to node

With this change we can rewrite example above as following:

  event.map = fn => {
    const mapped = createEvent()

    // create new node
    // and automatically add it to the event's next nodes
    createNode({
      from: event,
      seq: [compute(fn)],
      to: mapped,
    })

    return mapped
  }

I wont show you all diffs of all createNode function occurrences, the changes are trivial, you can make them yourself, or check commit by the link in the end of the article, as usual :)

Let's move on to the API methods!


forward

export const forward = ({ from, to }) => {
  createNode({
    from,
    to,
  })
}

That's simple :)

⚠️ Well, not quite so, Effector's Forward returns so called Subscription, to be able to remove connection. We will implement subscriptions in later chapters.

forward

Remember we can pass array of units/nodes to createNode function, so forward can handle arrays automatically!

forward arrays

merge

export const merge = (...events) => {
  const event = createEvent()
  forward({
    from: events.flat(), // to support both arrays and rest parameters
    to: event,
  })
  return event
}

merge creates new event and forwards all given events to that new one.

⚠️ Effector's merge supports only arrays. I've added rest parameters support just because I can ^_^

merge

split

const not = fn => value => !fn(value) // inverts comparator function

export const split = (event, cases) => {
  const result = {}

  for (const key in cases) {
    const fn = cases[key]
    result[key] = event.filter(fn)
    event = event.filter(not(fn))
  }

  result.__ = event
  return result
}

split function splits event into several events, which fire if source event matches corresponding comparator function.

"It may seem difficult at first, but everything is difficult at first."
— Miyamoto Musashi

So, take your time understanding this function.
And here is diagram of split:

split

Or in a less detailed, but more beautiful form of a tree, split is actually looks like a recursive binary splitting:

split tree

createApi

export const createApi = (store, setters) => {
  const result = {}

  for (const key in setters) {
    const fn = setters[key]
    result[key] = createEvent()
    store.on(result[key], fn)
  }

  return result
}

createApi function is just a simple factory for events, and it auto-subscribes given store on each one of them.

is

We can distinguish events and stores by doing typeof (events are functions and stores are plain objects). But this approach has a flaw – when we will implement effects it will fail, because effects are functions too. We could go further and check all properties – this is called duck typing. But Effector does that very simple – each unit has a special field kind:

export const createEvent = () => {
  // --8<--
+  event.kind = 'event'
  return event
}

export const createStore = defaultState => {
  // --8<--
+  store.kind = 'store'
  return store
}

And with this field kind we can easily check our units:

const is = type => any =>
  (any !== null) &&
  (typeof any === 'function' || typeof any === 'object') &&
  ('kind' in any) &&
  (type === undefined || any.kind === type)

export const unit = is()
export const event = is('event')
export const store = is('store')

restore

restore behaves differently on different inputs, so we will need our brand new is functionality:

export const restore = (unit, defaultState) => {
  if (is.store(unit)) {
    return unit
  }

  if (is.event(unit)) {
    return createStore(defaultState).on(unit, (_, x) => x)
  }

  const result = {}
  for (const key in unit) {
    result[key] = createStore(unit[key])
  }
  return result
}

restore function can also handle effects, but we don't have them yet.


Other API functions, like sample, guard and combine, we will describe in later chapters.

And as always, you can find all this chapter changes in this commit.

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