Skip to content

set-state/core

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@set-state/core

npm version Build Status Coverage Status License: MIT Codestyle Prettier Standard Version conduct

state management in less than 1 Kb

By Paul Grenier (@AutoSponge)

After using Redux, I looked for a simpler approach for some of my work. I also wanted the flexibility to manage state at the part level rather than the app level. I wanted to keep the reactive aspects that I liked in Redux, but be able to react to smaller, scoped changes (like a cursor).

@set-state helps you simplify state management. In @set-state, changes are synchronous. This makes app logic easier to write and understand. It was heavily inspired by PureState. @set-state/core was designed to be small yet flexible so you can create your own types of systems.

Getting Started

npm install --save @set-state/core

// import factory, {state} from '@set-state/core'
const factory = require('./dist/core')
const state = factory.state

How to use it

@set-state uses three main types of functions:

Factories create states and states create nodes. Nodes make up the heart of @set-state. When nodes read the values of other nodes, they create a graph/tree structure. This tree will update "downstream" nodes when "upstream" nodes change.

Other types of functions, plugin, listener, and projection, allow you to change the behavior of nodes and respond to changes in value.

See the API docs.

// Stateful variables are just JS values wrapped with a `state` call.
// Since the resulting function acts like a container, you can use `const`
const x = state(0)

// This reads a stateful variable's current value
x() // => 0

// This writes a stateful variable, changing it's current value
x(1)

// Stateful variables can depend on other stateful variables
const y = state(() => x() + 1)
const z = state(() => [x(), y(), x() + y()])

x() // => 1
y() // => 2
z() // => [1, 2, 3]

// If you change a stateful variable, all variables that depend on it are updated synchronosly.

x(10) // sets x to 10

x() // => 10
y() // => 11
z() // => [10, 11, 21]

// If you use branching logic in a stateful variable, set the dependencies as default arguments to avoid issues with the dependency tree not updating
const a = state(true)
const b = state(2)
const c = state(($b = b()) => a() ? 1 : $b)
a(false)
c() // => 2
a(true)
b(3)
c() // => 1
a(false)
c() // => 3

Factory

Factory functions, like the default export of core, can create state functions that use any number of custom plugins. You can pass a factory a context which can be used to differentiate state or node functions.

factory
/*
{ [Function: factory]
  isState: [Function: isState],   <== fn to identify states
  state:
   { [Function: state]
   ...
   context: 'set-state:core' }}
*/

// For most applications, you'll only need a single context
// but creating a separate graph (like for a web worker) allows for
// separate graphs to maintain their own synchonous graphs.
state.context // => 'set-state:core'
const workerState = factory('worker')
workerState.context // => 'worker'
const w1 = workerState(1)
w1() // => 1
w1.context // => 'worker'
workerState.isOwnNode(w1) // => true
state.isNode(w1) // => true
state.isNode(w1()) // => false
state.isOwnNode(w1) // => false

State

State functions, like the named state export of core, create node functions. Create complex apps by composing state nodes and reacting to changes.

state
/*
{ [Function: state]
  use: [Function],        <== add a plugin
  isOwnNode: [Function],  <== compare contexts of a node
  plugins: Set {},        <== list of plugins
  updating: Set {},       <== list of updating nodes
  capturing: Set {},      <== list of recalculating nodes
  is: Symbol(set-state),  <== type identification symbol
  of: [Circular],         <== alias state()
  END: Symbol(set-state:end),       <== symbol passed to end/freeze a node
  GUARD: Symbol(set-state:guard),   <== symbol passed to seal a node
  isNode: [Function: isNode],       <== fn to identify nodes
  end: [Function],                  <== end/freeze a node
  freeze: [Function],               <== end/freeze a node
  seal: [Function],                 <== seal a node
  isSealed: [Function: isSealed],   <== determine if a node is sealed
  isFinished: [Function: isFrozen], <== determine if a node is is frozen
  isFrozen: [Function: isFrozen],   <== alias isFinished
  context: 'set-state:core' }       <== context pass to the factory
*/

Plugin

Factories can also take a list of plugins. Plugins run like middleware when new nodes are created. You can also add individual plugins to a state later using the .use() method. Plugins take node as a parameter. Most plugins will attach methods to the node (like a serialize method) or create new, dependent nodes called projections. But you're not limited to those actions.

// Create a JSON serializer.
const isPrimitive = x => Object(x) !== x
const native = x => (x.toJSON === undefined ? x : x.toJSON())
const toJSON = node => {
  node.toJSON = () => isPrimitive(node.value) ? node.value : native(node.value)
}
const serializableState = factory().use(toJSON)
const s1 = serializableState({_id: 1, created: new Date(Date.UTC(2018, 0, 1))})
JSON.stringify(s1) // => '{"_id":1,"created":"2018-01-01T00:00:00.000Z"}'

// Create a projection.
const map = node => node.map = fn => node.state(() => fn(node())).seal()
state.use(map)
const m1 = state(10)
const m2 = m1.map(n => n * n)
m1() // => 10
m2() // => 100
m2(`this won't worked, it's sealed`) // => 100

// Create a plugin that keeps node value types the same.
const setValue = node => {
  node.set = val => {
    if (typeof val === typeof node.value) {
      return node(val)
    }
    // throw new TypeError(...)
  }
}
const typedState = factory('typed', [setValue])
const t1 = typedState(new Date(Date.UTC(2018, 0, 1)))
t1.set(Date.now()) // ignored update since Date.now() is a number
t1().toUTCString() // => 'Mon, 01 Jan 2018 00:00:00 GMT'

// Create a "safe" plugin
const myPlugin = node => {
  // namespace your plugin
  const name = 'myPlugin'
  const state = node.state
  // isolate this plugin's vars
  const vars = node.locals[name] = node.locals[name] || {}
  const pluginFn = () => {/* ...node, state, vars */}
  // check that the plugin's namespace isn't taken
  if (!node[name]) {
    node[name] = pluginFn
  } else {
    // throw new Error(`unable to use plugin: ${name}, namespace in use.`)
  }
}

// You can interact with the plugins added
state.plugins.size // => 1
serializableState.plugins.has(toJSON) // => true
typedState.plugins.clear()

Node

Nodes properties help keep values consistent across a context. You can also use these properties when writing your own plugins.

state(0)
/*
{ [Function: node]         <== this is your new node
  locals: {},              <== this is where you can put variables from plugins
  state:                   <== this is a ref to the state fn that created this node
   { [Function: state]
     ...
     context: 'set-state:core' },
  is: Symbol(set-state:node), <== type identification symbol
  context: 'set-state:core',  <== context will match the state that created it
  listeners: Set {},        <== you can tell if a node has listeners
  dependents: Set {},       <== you can tell if a node has dependents
  dependencies: Set {},     <== you can tell if a node has dependencies
  on: [Function],           <== add a listener
  seal: [Function],         <== seal this node
  end: [Function],          <== freeze/end this node
  freeze: [Function],       <== freeze/end this node
  toString: [Function],     <== convenience fn
  valueOf: [Function],      <== convenience fn
  compute: [Function],      <== value as a fn (compute.toString())
  value: 0 }                <== the raw value (always up-to-date)
*/

Projection

Projections are special nodes. By convention, all projections depend on at least one other node for their value and are "sealed". This means that the function used to calculate their value can not be changed after they are created.

const p0 = state('beep')

// simple projections of p0
const p1 = state.seal(() => `${p0()}!`)
const p2 = state(() => p0().replace(/e/g, 'o')).seal()
const p3 = state(() => `${p0()} ${p2()} ${p1()}` )
p1() // => 'beep!'
p2() // => 'boop'
p3() // => 'beep boop beep!'

// projection using a plugin
const either = node =>
  node.either = (a, b) =>
    node.state(() => // <<< NOTE: use the 'state' from the same context
      a(node()) || b(node())).seal()

state.use(either)
const e0 = state(6)
const e1 = e0.either(n => n % 3, n => n % 2)
e1() // => 0
e0(9)
e1() // => 1

Listener

A listener gets called when a change occurs on the node attached to it. When called, a listener receives nextValue and previousValue as arguments.

const l0 = state('#foo')
const l1 = state('foo')
const link = state((href = l0(), text = l1()) => ({href, text}))
const render = ({href, text}) => {
  root.innerHTML = `<a href="${href}">${text}</a>`
}
link.on(render)
// listeners only fire on change
root.innerHTML // => ''
l1('bar')
root.innerHTML // => '<a href="#foo">bar</a>'