Skip to content

e280/strata

Repository files navigation



⛏️ strata

get in loser, we're managing state

📦 npm install @e280/strata
✨ it's basically about automagically rerendering ui when data changes
🦝 powers auto-reactivity in our view library @e280/sly
🧙‍♂️ probably my tenth state management library, lol
🧑‍💻 a project by https://e280.org/

🚦 #signals, sweet little bundles of state
🔮 #prism, bigger centralized state trees
#wait, representing async operations
🪄 #tracker, agnostic reactivity integration hub
⚛️ #react, optional bindings for react



🍋 strata signals

reactive bundles of joy

import {signal, effect, derived, lazy} from "@e280/strata"

🚦 each signal holds a value

  • make signal
    const $count = signal(0)

    maybe you like the $ prefix convention for signals?

  • read signal
    $count() // 0
  • write signal
    $count(1)
  • 🤯 await all downstream effects
    await $count(2)

    this is supposed to impress you

🚦 pick your poison

  • signal hipster-fn syntax
    $count()        // read
    await $count(2) // write
  • signal get/set syntax
    $count.get()        // read
    await $count.set(2) // write
  • signal .value accessor syntax
    $count.value     // read
    $count.value = 2 // write
    value pattern is super nice for these vibes
    $count.value++
    $count.value += 1

🚦 effects

  • effects run when the relevant signals change
    effect(() => console.log($count()))
      // 1
      // the system detects '$count' is relevant
    
    $count.value++
      // 2
      // when $count is changed, the effect fn is run

🚦 .on listeners

  • yes, you can do direct callbacks to listen for changes
    const off = $count.on(count => console.log(`callback ${count}`))
    
    $count.value++
      // "callback 3"
    
    off()
      // stop listening

🚦 derived signals

  • derived, for combining signals, like a formula
    const $a = signal(1)
    const $b = signal(10)
    const $product = derived(() => $a() * $b())
    
    $product() // 10
    
    // change a dependency,
    // and the derived signal is automatically updated
    await $a(2)
    
    $product() // 20
  • lazy, for making special optimizations.
    it's like derived, except it cannot trigger effects,
    because it's so damned lazy, it only computes the value on read, and only when necessary.

    i repeat: lazy signals cannot trigger effects!

🚦 types and such

  • Signaly<Value> — can be Signal<Value> or Derived<Value> or Lazy<Value>
    • these are types for the core primitive classes
  • the classes are funky
    • Signal, Derived, and Lazy classes cannot be subclassed or extended, due to spooky magic we've done to make the instances callable as functions (hipster syntax).
    • however, at least $count instanceof Signal works, so at least that's working.



🍋 strata prism

persistent app-level state

  • single-source-of-truth state tree
  • no spooky-dookie proxy magic — just god's honest javascript
  • immutable except for mutate(fn) calls
  • use many lenses, efficient reactivity
  • chrono provides undo/redo history
  • persistence, localstorage, cross-tab sync

🔮 prism and lenses

  • import prism
    import {Prism} from "@e280/strata"
  • prism is a state tree
    const prism = new Prism({
      snacks: {
        peanuts: 8,
        bag: ["popcorn", "butter"],
        person: {
          name: "chase",
          incredi: true,
        },
      },
    })
  • create lenses, which are views into state subtrees
    const snacks = prism.lens(state => state.snacks)
    const person = snacks.lens(state => state.person)
    • you can lens another lens
  • lenses provide snapshot access to state
    // .state is a mutable snapshot with relaxed typings
    snacks.state.peanuts // 8
    person.state.name // "chase"
    
    // .frozen is an immutable snapshot with strict typings
    snacks.frozen.peanuts // 8
    snacks.frozen.peanuts++
      // ⛔ error: casual mutations forbidden
  • only formal mutations can actually change state
    snacks.mutate(state => state.peanuts++)
      // ✅ formal mutations to change state
    
    snacks.state.peanuts // 9
  • array mutations are unironically based, actually
    await snacks.mutate(state => state.bag.push("salt"))

🔮 chrono for time travel

  • import stuff
    import {Chrono, chronicle} from "@e280/strata"
  • create a chronicle in your state
    const prism = new Prism({
    
        // chronicle stores history
        //        👇
      snacks: chronicle({
        peanuts: 8,
        bag: ["popcorn", "butter"],
        person: {
          name: "chase",
          incredi: true,
        },
      }),
    })
    • big-brain moment: the whole chronicle itself is stored in the state.. serializable.. think persistence — user can close their project, reopen, and their undo/redo history is still chillin' — brat girl summer
  • create a chrono-wrapped lens to interact with your chronicle
    const snacks = new Chrono(64, prism.lens(state => state.snacks))
      //                      👆
      // how many past snapshots to store
  • mutations will advance history, and undo/redo works
    snacks.mutate(s => s.peanuts = 101)
    
    snacks.undo()
      // back to 8 peanuts
    
    snacks.redo()
      // forward to 101 peanuts
  • check how many undoable or redoable steps are available
    snacks.undoable // 1
    snacks.redoable // 0
  • you can make sub-lenses of a chrono, all their mutations advance history too
  • plz pinky-swear right now, that you won't create a chrono under a lens under another chrono 💀

🔮 persistence to localStorage

  • import prism
    import {Vault, LocalStore} from "@e280/strata"
  • create a local storage store
    const store = new LocalStore("myAppState")
  • make a vault for your prism
    const vault = new Vault({
      prism,
      store,
      version: 1, // 👈 bump this when you break your state schema!
    })
    • store type is compatible with @e280/kv
  • cross-tab sync (load on storage events)
    store.onStorageEvent(vault.load)
  • initial load
    await vault.load()



🍋 strata wait

represent async operations

  • wait is designed to vibe with stz#ok
    import {ok, err} from "@e280/stz"

⌛ wait primitives

  • imports
    import {newWait} from "@e280/strata"
  • helpers to create a Wait
    // loading
    newWait<number>()
      // {done: false}
    
    // done, ok
    newWait(ok(123))
      // {done: true, ok: true, value: 123}
    
    // done, err
    newWait(err("uh oh"))
      // {done: true, ok: false, error: "uh oh"}

⌛ make a magic reactive wait signal

  • imports
    import {nap} from "@e280/stz"
    import {wait} from "@e280/strata"
  • it's a derived signal (readonly) that tracks the result of an async fn or promise
    const $wait = wait(async() => {
      await nap(100) // do some async stuff
      return 123 // return your value
    })
  • read the current state from the signal
    $wait()
      // {done: false}
  • later, when it's ready
    await $wait.ready
      // 123
      // undefined if there was an error
    
    $wait()
      // {done: true, ok: true, value: 123}

⌛ persnickety belt-and-suspenders mode

  • imports
    import {waitResult} from "@e280/strata"
  • do formal rigid error handling because you're super strict and serious
    const $wait = waitResult<number, "unlikely lol" | "bad roll">(async() => {
      if (Math.random() > 0.5)
        return ok(123)
    
      if (Math.random() < 0.01)
        return err("unlikely lol")
    
      else
        return err("bad roll")
    })
  • listen for the formal result
    await $wait.result
      // {done: true, ok: true, value: 123} or
      // {done: true, ok: false, error: "bad roll"}
  • btw, wait and waitResult will actually accept a promise if you like
    const $wait = wait(Promise.resolve(123))

⌛ wait helpers

  • check the state
    isWaitPending($wait())
    isWaitDone($wait())
    isWaitOk($wait())
    isWaitErr($wait())
  • get the finished value or error
    waitGetOk($wait()) // 123 | undefined
    waitNeedOk($wait()) // 123 (or throws an error)
    waitGetErr($wait()) // "bad roll" | undefined
    waitNeedErr($wait()) // "bad roll" (or throws an error)
  • select based on the state
    const text = waitSelect($wait(), {
      pending: () => "still loading...",
      ok: value => `ready: ${value}`,
      err: error => `ack! ${error}`,
    })



🍋 strata tracker

reactivity integration hub

import {tracker} from "@e280/strata/tracker"

if you're some kinda framework author, making a new ui thing, or a new state concept -- then you can use the tracker to jack into the strata reactivity system, and suddenly your stuff will be fully strata-compatible, reactin' and triggerin' with the best of 'em.

the tracker is agnostic and independent, and doesn't know about strata specifics like signals or trees -- and it would be perfectly reasonable for you to use strata solely to integrate with the tracker, thus making your stuff reactivity-compatible with other libraries that use the tracker, like sly.

note, the items that the tracker tracks can be any object, or symbol.. the tracker cares about the identity of the item, not the value (tracker holds them in a WeakMap to avoid creating a memory leak)..

🪄 integrate your ui's reactivity

  • we need to imagine you have some prerequisites
    • myRenderFn -- your fn that might access some state stuff
    • myRerenderFn -- your fn that is called when some state stuff changes
    • it's okay if these are the same fn, but they don't have to be
  • tracker.observe to check what is touched by a fn
    // 🪄 run myRenderFn and collect seen items
    const {seen, result} = tracker.observe(myRenderFn)
    
    // a set of items that were accessed during myRenderFn
    seen
    
    // the value returned by myRenderFn
    result
  • it's a good idea to debounce your rerender fn
    import {microbounce} from "@e280/stz"
    const myDebouncedRerenderFn = microbounce(myRerenderFn)
  • tracker.subscribe to respond to changes
    const stoppers: (() => void)[] = []
    
    // loop over every seen item
    for (const item of seen) {
    
      // 🪄 react to changes
      const stop = tracker.subscribe(item, myDebouncedRerenderFn)
    
      stoppers.push(stop)
    }
    
    const stopReactivity = () => stoppers.forEach(stop => stop())

🪄 integrate your own novel state concepts

  • as an example, we'll invent the simplest possible signal
    export class SimpleSignal<Value> {
      constructor(private value: Value) {}
    
      get() {
    
        // 🪄 tell the tracker this signal was accessed
        tracker.notifyRead(this)
    
        return this.value
      }
    
      async set(value: Value) {
        this.value = value
    
        // 🪄 tell the tracker this signal has changed
        await tracker.notifyWrite(this)
      }
    }



🍋 react bindings

⚛️ setup your strata.ts module

import * as react from "react"
import {react as strata} from "@e280/strata"

export const {
  component,
  useStrata,
  useOnce,
  useSignal,
  useDerived,
} = strata(react)

⚛️ component enables fully automatic reactive re-rendering

import {component} from "./strata.js"

const $count = signal(0)

export const MyCounter = component(() => {
  const add = () => $count.value++
  return <button onClick={add}>{$count()}</button>
})

⚛️ useStrata for a manual hands-on approach (plays nicer with hmr)

import {useStrata} from "./strata.js"

const $count = signal(0)

export const MyCounter = () => {
  const count = useStrata(() => $count())
  const add = () => $count.value++
  return <button onClick={add}>{count}</button>
}

⚛️ useSignal for local component state

import {useSignal} from "./strata.js"

export const MyCounter = () => {
  const $count = useSignal(0)
  const add = () => $count.value++
  return <button onClick={add}>{$count()}</button>
}



🧑‍💻 strata is by e280

free and open source by https://e280.org/
join us if you're cool and good at dev

About

⛏️ incredi state management

Topics

Resources

License

Stars

Watchers

Forks

Contributors