Miss any of our Open RFC calls?Watch the recordings here! »

@-0/hdom

0.2.80 • Public • Published

spule

Spule ~ Scottish: [ˈspül] variant of Spool ~ German: [ˈʃpuːlə] Coil, Reel

Introduction: State MGMT and Side-Effects

All too often, state management (MGMT) is an "add-on", an afterthought of a UI/API. However, you may realize by now - if you've spent any significant time using the available MGMT libraries - that state (and coupled effects) is the most infuriating source of complexity and bugs in modern JavaScript apps. Spule aims to be far simpler than the status quo by starting with a simple abstraction over the hardest parts and working outwards to the easier ones.

Getting Started

npm install spule

What if you could compose your app logic on an ad-hoc basis without creating spagetthi code? Yes, it's possible and one of the primary goals of spule

At it's core, spule is async-first. It allows you to write your code using async/await/Promises in the most painless and composable way you've ever seen. spule does some stream-based (FRP) gymnastics under the hood to correograph everything, however, you won't have to worry about the implementation details. spule aims at being approachable to those who have zero experience with streams. Let's see some examples.

Commands

At the core of spule is an async spooler (hence the name), which recieves "Commands" and responds to them. We'll go into more detail later, but let's jump right in with some copy/paste examples.

Stupid Command example:

// src/genie.js
 
import { run$, registerCMD } from "spule"
 
const GENIE = registerCMD({
  sub$: "GENIE",
  args: "your wish",
  work: x => console.log("🧞‍♀️:", x, "is my command")
})
 
// work handler is digested during registration
 
console.log(GENIE)
 
// => { sub$: "GENIE", args: "your wish" }
 
run$.next(GENIE)
 
//=> 🧞‍♀️: your wish is my command

registerCMD takes a config Object, attaches the work callback to a pubsub stream for you and returns a Command Object that you can use to trigger that callback (subscription based on the Command sub$ value).

This Object signature is not only handy as a means to manage a lot of Commands, but it also avails spule's superpower: Tasks

Tasks

Tasks, like Commands, are just data (including Lambdas). Commands are Objects and Tasks are Arrays of Commands. This allows them to be dis/reassembled and reused on an ad-hoc basis. Let's compose our GENIE Command with an API call...

// src/genie.js (continued)
 
export const GET__FORTUNE = [
  // 1st Command args' Object initializes an accumulator
  { args: { api: "http://yerkee.com/api/fortune" } },
 
  // lambda args have access to the accumulation
  {
    args: ({ api }) => fetch(api).then(r => r.json()),
    reso: (acc, { fortune }) => ({ fortune }),
    erro: (acc, err) => ({ error: err })
  }
]
 
const FORTUNE__GENIE = [
  ...GET__FORTUNE,
  { ...GENIE, args: ({ fortune }) => fortune }
]
 
run$.next(FORTUNE__GENIE)
 
// => 🧞‍♀️: Deliver yesterday, code today, think tomorrow. is my command

Logic as Data™

As you can see - within a Task - the only required key on a Command Object is the args key, which provide the signal-passing functionality between intra-Task Commands. The only Command that actually does any work here is GENIE (the one with a registered sub$).

🔍 UTH (Under the Hood): This intra-Task handoff works via an async reduce function. Any Object returned by a Command is spread into an "accumulator" that can be accessed by any following Commands within a Task (via a unary Lambda in the args position).

Hopefully you get a sense of how handy this is already. Have you ever wished you could pull out and pass around a .then from one Promise chain to compose with another? Well, now you - effectively - can. Not only can you recombine Promises with Tasks, you can also recombine side-effecting code. This is "Logic as Data"™

And, yes, it gets even better. It may be obvious that you can de/compose or spread together Tasks (they're just Arrays). But, what if the shape/signature of your "Subtask" doesn't match that of the Task that you'd like spread it into?

Subtasks

// src/zoltar.js
 
import { run$, registerCMD } from "spule"
 
import { GET__FORTUNE } from "./genie"
 
const ZOLTAR = registerCMD({
  sub$: "ZOLTAR",
  args: { zoltar: "make your wish" },
  work: ({ zoltar }) => console.log("🧞‍♂️:", zoltar)
})
 
const TOM = registerCMD({
  sub$: "TOM",
  args: { tom: "👶: I wish I were big" },
  work: ({ tom }) => console.log(tom)
})
 
/**
 * use a unary function that takes the accumulator
 * Object and returns a Task
 */
const ZOLTAR__X = ({ zoltar }) => [
  { ...TOM, args: { tom: "🧒: I wish I was small again" } },
  { ...ZOLTAR, args: { zoltar } }
]
 
const BIG__MORAL = [
  ZOLTAR,
  TOM,
  { ...ZOLTAR, args: { zoltar: "your wish is granted" } },
  ...GET__FORTUNE,
  ({ fortune }) => ZOLTAR__X({ zoltar: fortune })
]
 
run$.next(BIG__MORAL)
 
//=> 🧞‍♂️: make your wish
 
//=> 👶: I wish I were big
 
//=> 🧞‍♂️: your wish is granted
 
//=> 🧒: I wish I was small again
 
//=> 🧞‍♂️: Growing old is mandatory; growing up is optional.

Just as using a unary args function in a Command allows passing state between Commands, you can use a unary function within a Task to pass state between Subtasks.

Goodbye 🍝 Code!

This gives new meaning to the term "side-effect" as - in spule - side-effects are kept on the side and out of the guts of your logic. This frees you from the pain that tight-coupling of state, side-effects and logic entails. Every feature is strongly decoupled from the others providing a DX that is versatile, modular and composable.

TODO: IMAGE(s) ♻ Framework Architecture

Command Keys

Key Type Role Required for
args Any Command payload/accumulator transforming lambda always
sub$ String Pubsub stream topic: connects Command to handler work
work Lambda dispatch side-effects/state-updates on Command "work"
reso Lambda Promise args resolution handler Promises
erro Lambda Promise args rejection handler Promises
src$ Stream Upstream/source stream (advanced) optional

The SET_STATE Command (built-in)

TODO

Shorthand Symbols Glossary (spule surface grammar)

Now that we've seen some examples of Commands and Tasks in use, we'll use a shorthand syntax for describing Task/Command signatures as a compact conveyance when convenient.

Symbol Description
{C} Command Object
{*} Object
# Primitive value (boolean, string, number)
{?} Promise
{A} Accumulator Object
(*) => Lambda with any number of parameters
(+) => Non-nullary lambda
(1) => Unary lambda
(0) => Nullary lambda (aka "thunk")
[{C},,] or [T] Task
[,,T,,] or [sT] Subtask

Router

One of the things that can be really frustrating to users of some frameworks is either the lack of a built-in router or one that seems tacked-on after the fact. spule was built with the router in mind.

spule provides two routers:

  1. A DOM router (for clients/SPAs)
  2. a data-only router (for servers/Node).

🔍 UTH: The DOM router is built on top of the data-only router. Both are implemented as Tasks.

URL = Lens

What is a URL? It's really just a path to a specific resource or collection of resources. Before the glorious age of JavaScript, this - in fact - was the only way you could access the Internet. You typed in a URL, which pointed to some file within a directory stored on a computer at some specific address.

Taking queues from the best parts of functional programming, spule's router is really just a lens into the application state. As natural as URLs are to remote resources, this router accesses local memory using paths

At it's core the spule router doesn't do very much. It relies on a JavaScript Map implementation that retains the Map API, but has value semantics - rather than identity semantics (aka: PLOP), which the native Map implementation uses - for evaluating equality of a non-primitive Map keys (e.g., for Object/Array keys).

This - dare I say better - implementation of Map avails something that many are asking for in JS: pattern matching. With pattern matching, we don't have to resort to any non-intuitive/complex/fragile regular expression gymnastics for route matching.

To start, we'll diverge away from the problem at hand for just a moment look at some of the benefits of a value-semantic Map...

Value semantics have so many benefits. As a router, just one. So, how might we apply such a pattern matching solution against the problem of routing?

// src/routes.js
import { EquivMap } from "@thi.ng/associative"
 
const known = x => ["fortunes", "lessons"].find(y => y === x)
const four04 = [{ chinese: 404, english: 404 }]
const home = [{ chinese: "", english: "home" }]
const url = "https://fortunecookieapi.herokuapp.com/v1/"
const query = (a, b) =>
  fetch(`${url}${a}?limit=1&skip=${b}`).then(r => r.json())
 
export const match = async path => {
  const args = path ? path.split("/") : []
 
  let [api, id] = args
 
  const data =
    new EquivMap([
      // prevent unneeded requests w/thunks (0)=>
      [[], () => home],
      [[known(api), id], () => query(api, id)], // guarded match
      [[known(api)], () => query(api, 1)] // guarded match
    ]).get(args) || (() => four04)
 
  // call the thunk to trigger the actual request
  const res = await data()
  const r = res[0]
 
  return r.message || `${r.chinese}${r.english}`
}
 
const log = console.log
 
match("fortunes/88").then(log)
// //=> "A handsome shoe often pinches the foot."
 
match("").then(log)
// //=> "家: home"
 
match("lessons/4").then(log)
// //=> "请给我一杯/两杯啤酒。: A beer/two beers, please."
 
match("bloop/21").then(log)
// //=> "404: 404"

If you can see the potential of pattern matching for other problems you may have encountered, you can check out the more detailed section later. We can create pattern-matching guards by using an in situ expression that either returns a "falsy" value or the value itself.

Even if you don't end up using spule, you may find the @thi.ng/associative library very handy!

Now, let's integrate our router. Everything pretty much stays the same, but we'll need to make a few changes to mount our router to the DOM.

// src/routes.js
 
import { parse } from "spule"
 
...
 
export const match = async path => {
- const args = path ? path.split("/") : [];
+ const args = parse(path).URL_path
 
  let [api, id] = args
 
  const data =
    new EquivMap([
      [[], () => home],
      [[known(api), id], () => query(api, id)],
      [[known(api)], () => query(api, 1)]
    ]).get(args) || (() => four04)
 
  const res = await data()
  const r = res[0]
 
- return r.message || `${r.chinese}: ${r.english}`
+ return {
+   URL_data: r.message || `${r.chinese}: ${r.english}`,
+ }
}
 
- ...

TODO

It's beyond the scope of this introduction to spule to dive into the implementation of our next example. It will work, but you try it out for yourself on your own (toy) problem in order to get a feel for it.

UI-first or UI-last?

As you may deduce - if you've gotten this far - is there's a heavy data-oriented/biased approach taken by spule. In fact, we argue that the UI should be informed by the data, not the other way around.

I.e., start with building out the application state for your various routes and then frame it with a UI. Think of the application state as your information architecture and the UI as your information interior design. While it's possible to start with the design and end with an information architecture, the customer journey can suffer from an over-reliance on "signage" for helping them navigate through the information.

It's not uncommon to start an application/site design with a "site map". Think of this approach like a site map on steroids

Advanced

ADVANCED USE ONLY 👽

HURL tries to hide the stream implentation from the user as much as possible, but allows you to go further down the rabbit hole if so desired. You may send Commands to a separate stream of your own creation during a Task by using a nullary ("thunk") (0)=> function signature as the args value of a Command. If this is the case, the spool assumes the sub$ key references a stream and sends the return value of the thunk to that stream This feature can come in handy for "fire and forget" events (e.g., logging, analytics, etc.)

import { stream } from "@thi.ng/rstream"
import { map, comp } from "@thi.ng/transducers"
// ad-hoc stream
let login = stream().subscribe(
  comp(
    map(x => console.log("login ->", x)),
    map(({ token }) => loginToMyAuth(token))
  )
)
// subtask ({A})=>
let ANALYTICS = ({ token }) => [
  {
    sub$: login, // <- stream
    // thunk custom stream dispatch (0)=>
    args: () => ({ token })
  }
]
// task
let task = [
  // no sub$, just pass data
  { args: { href: "https://my.io/auth" } },
  { sub$: login, args: () => "logging in..." },
  {
    sub$: "AUTH",
    args: ({ href }) => fetch(href).then(r => r.json()),
    erro: (acc, err) => ({ sub$: "cancel", args: err }),
    reso: (acc, res) => ({ token: res })
  },
  acc => ANALYTICS(acc),
  { sub$: login, args: () => "log in success" }
]

Stream Architecture:

run$ is the primary event stream exposed to the user via the ctx object injected into every hdom component the command stream is the only way the user changes anything in hurl

Marble Diagram

0>- |------c-----------c--[~a~b~a~]-a----c-> : calls
1>- |ps|---1-----------1----------0-1----1-> : run$
2>- |t0|---------a~~b~~~~~~~~~~~a~|--------> : task$
3>- |t1|---c-----------c------------a----c-> : command$
4>- ---|ps|c-----a--b--c--------a---a----c-> : out$
 
Userland Handlers:
 
a>- ---|ta|------*--------------*---*------> : registerCMD
b>- ---|tb|---------*----------------------> : registerCMD
c>- ---|tc|*-----------*-----------------*-> : registerCMD

Streams

  • 0>-: userland stream emmissions (run)
  • 1>-: pubsub forking stream (if emmission has a sub$)
  • 2>-: pubsub = false? -> task$ stream
  • 3>-: pubsub = true? -> command$ stream
  • 4>-: pubsub emits to registerCMD based on sub$ value

work Handlers

  • 4>- this is the stream to which the user (and framework) attaches work handlers. Handlers receive events they subscribe to as topics based on a sub$ key in a Command object.

Built-in Commands/Tasks:

  • SET_STATE: Global state update Command
  • URL__ROUTE: Routing Task
  • "FLIP" : F.L.I.P. animations Commands for route/page transitiions

run$

User-land event dispatch stream

This stream is directly exposed to users. Any one-off Commands nexted into this stream are sent to the command$ stream. Arrays of Commands (Tasks) are sent to the task$ stream.

Constants Glossary

URL component key description
DOM DOM node target
URL full URL/route
URL_path route path as array
URL_domain top-level domain as array
URL_subdomain subdomain as array
URL_query node querystring parsed URL parameters
URL_hash hash string to/from URL if any
URL_data data returned by router
URL_page page component to render URL_data with
router config key description
HEAD metadata wrapper for router (targets DOM )
BODY data wrapper for router
prep pre-router behavior Task/Command injection
post post=router behavior Task/Command injection
prefix URL path string for the router to ignore
router @thi.ng/EquivMap pattern matching function
Command key (🔎) description
sub$ Command primary/unique key (topic subscription)
args signal passing intra-Task Command state value
reso Promise resolution handler
erro Promise rejection handler
work where Commands' actual "work" is done
src$ upstream (source stream) Command connector
boot config key description
run primary userland dispatch function
state global immutable state container
root DOM mount node for application
app root application view
trace enable logging of every global state update
draft state shape scaffolding

More Pattern Matching

import { EquivMap } from "@thi.ng/associative"
 
const haiku = args => {
  const { a, b, c } = args
  const [d] = c || []
 
  const line =
    new EquivMap([
      [{ a, b }, `${a} are ${b}`],
      [{ a, b, c: [d] }, `But ${a} they don't ${b} ${d}`]
    ]).get(args) || "refrigerator"
 
  console.log(line)
}
 
haiku({ a: "haikus", b: "easy" })
//=> haikus are easy
 
haiku({ a: "sometimes", b: "make", c: ["sense"] })
//=> But sometimes they don't make sense
 
haiku({ b: "butterfly", f: "cherry", a: "blossom" })
//=> refrigerator

We can use any expression in the context of an Object as a guard. Let's see an example of guarding matches for Objects...

let guarded_matcher = args => {
  let { a, c } = args
 
  let res =
    // for guards on objects use computed properties
    new EquivMap([
      [{ a, [> 3 && "c"]: c }, `${c} is greater than 3`],
      [{ a, [< 3 && "c"]: c }, `${c} is less than 3`]
    ]).get(args) || "no match"
 
  console.log(res)
}
 
guarded_matcher({ a: "b", c: 2 })
//=> less than 3
 
guarded_matcher({ a: "b", c: 3 })
//=> no match
 
guarded_matcher({ a: "b", c: 4 })
//=> greater than 3
  • Naming Conventions:
    • constants: CAPITAL_SNAKE_CASE
      • generally accepted convention for constants in JS
      • used for defining Commands (as though they might cause side effects, their subscription names are constant - i.e., a signal for emphasising this aspect of a Command)
    • pure functions: snake_case
      • some novelty here due to pure functions acting like constants in that with the same input they always return the same output
    • impure functions: camelCase
      • regular side-effecty JS
    • Tasks: DOUBLE__UNDERSCORE__SNAKE__CASE
      • implies the inputs and outputs on either end of a Task
      • Tasks also should be treated as pure functions where the output is really just data (and lambdas). This is going in the direction of "code as data"
  • lots'o'examples

Credits

spule is built on the @thi.ng/umbrella ecosystem

Install

npm i @-0/hdom

DownloadsWeekly Downloads

94

Version

0.2.80

License

MIT

Unpacked Size

28.6 kB

Total Files

11

Last publish

Collaborators

  • avatar