📦 npm install @e280/strata
🧙♂️ probably my tenth state management library, lol
💁 it's all about rerendering ui when data changes
🦝 used by our view library @e280/sly
🧑💻 a project by https://e280.org/
🚦 signals — ephemeral view-level state
🌳 tree — persistent app-level state
🪄 tracker — reactivity integration hub
ephemeral view-level state
import {signal, effect} from "@e280/strata"
- create a signal
const count = signal(0)
- read a signal
count() // 0
- set a signal
count(1)
- set a signal, and await effect propagation
await count(2)
- signal hipster fn syntax
count() // get await count(2) // set
see the discussion about this controversial hipster-syntax
- signal get/set syntax
count.get() // get await count.set(2) // set
- signal .value accessor syntax
value pattern is nice for this vibe
count.value // get count.value = 2 // set
count.value++ count.value += 1
- 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
- signal.derive
is for combining signalsconst a = signal(1) const b = signal(10) const product = signal.derive(() => a() * b()) product() // 10 // change a dependency, // and the derived signal is automatically updated await a.set(2) product() // 20
- signal.lazy
is for making special optimizations.
it's like derive, except it cannot trigger effects,
because it's so lazy it only computes the value on read, and only when necessary.i repeat: lazy signals cannot trigger effects!
persistent app-level state
import {Trunk} from "@e280/strata"
- single-source-of-truth state tree
- immutable except for
mutate(fn)
calls - localStorage persistence, cross-tab sync, undo/redo history
- no spooky-dookie proxy magic — just god's honest javascript
- better stick to json-friendly serializable data
const trunk = new Trunk({ count: 0, snacks: { peanuts: 8, bag: ["popcorn", "butter"], }, }) trunk.state.count // 0 trunk.state.snacks.peanuts // 8
- ⛔ informal mutations are denied
trunk.state.count++ // error is thrown
- ✅ formal mutations are allowed
await trunk.mutate(s => s.count++)
- it's a lens, make lots of them, pass 'em around your app
const snacks = trunk.branch(s => s.snacks)
- run branch mutations
await snacks.mutate(s => s.peanuts++)
- array mutations are unironically based, actually
await snacks.mutate(s => s.bag.push("salt"))
- you can branch a branch
- on the trunk, we can listen deeply for mutations within the whole tree
trunk.on(s => console.log(s.count))
- whereas branch listeners don't care about changes outside their scope
snacks.on(s => console.log(s.peanuts))
- on returns a fn to stop listening
const stop = trunk.on(s => console.log(s.count)) stop() // stop listening
only discerning high-class aristocrats are permitted beyond this point
- it automatically handles persistence to localStorage and cross-tab synchronization
- simple setup
const {trunk} = await Trunk.setup({ version: 1, // 👈 bump whenever you change state schema! initialState: {count: 0}, })
- uses localStorage by default
- it's compatible with
@e280/kv
import {Kv, StorageDriver} from "@e280/kv" const kv = new Kv(new StorageDriver()) const store = kv.store<any>("appState") const {trunk} = await Trunk.setup({ version: 1, initialState: {count: 0}, persistence: { store, onChange: StorageDriver.onStorageEvent, }, })
- first, put a
Chronicle
into your state treeconst trunk = new Trunk({ count: 0, snacks: Trunk.chronicle({ peanuts: 8, bag: ["popcorn", "butter"], }), })
- 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
- second, make a
Chronobranch
which is like a branch, but is concerned with historyconst snacks = trunk.chronobranch(64, s => s.snacks) // \ // how many past snapshots to store
- mutations will advance history (undoable/redoable)
await snacks.mutate(s => s.peanuts = 101) await snacks.undo() // back to 8 peanuts await snacks.redo() // forward to 101 peanuts
- you can check how many undoable or redoable steps are available
snacks.undoable // 2 snacks.redoable // 1
- chronobranch can have its own branches — all their mutations advance history
- plz pinky-swear right now, that you won't create a chronobranch under a branch under another chronobranch 💀
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)..
- we need to imagine you have some prerequisites
myRenderFn
-- your fn that might access some state stuffmyRerenderFn
-- 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 {debounce} from "@e280/stz" const myDebouncedRerenderFn = debounce(0, myRerenderFn)
tracker.subscribe
to respond to changesconst 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())
- 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) } }
free and open source by https://e280.org/
join us if you're cool and good at dev