🔮 snapstate
tiny robust state management
📦 npm install @chasemoskal/snapstate
👁️ watch for changes to properties
🕵️ track only the properties you are reading, automatically
♻️ keeps you safe from circular updates
⛹️ updates are debounced, avoiding duplicate updates
🌳 carve large state trees into substates
🧬 implemented with recursive es proxies
🔬 typescript-native types, es modules
💖 free and open source, just for you
snapstate is designed to be a modern replacement for mobx. mobx was amazing, but has grown comically large at like 50 KB. mobx is also global, among other complications that we don't prefer.
snapstate is 1.5 KB, minified and gzipped.
🕵️ tracking changes to properties
- first, let's create some state.
import {snapstate} from "@chasemoskal/snapstate"
const snap = snapstate({
count: 0,
coolmode: "enabled",
also: {
nesting: {
isAllowed: true,
},
},
})
snap.track
allows us to track changes to the state
snap.track(state => {
console.log(state.count)
})
snap.state.count += 1
snap.state.coolmode = "super"
- if you prefer driving stick-shift, we can make a manual track and even avoid the initial run.
snap.track(
({count}) => ({count}),
({count}) => console.log(`count changed: ${count}`),
)
snap.state.count += 1
- of course, we can stop tracking things when we want.
const untrack = snap.track(({count}) => console.log(count))
snap.state.count += 1
untrack()
snap.state.count += 1
💾 control which parts of your app can write to state
this is a pillar of good state management.
if every part of our app can write to the state, all willy-nilly, it quickly becomes a convoluted mess that is hard to debug.
snap.readable
is just like snap.state
, except that it's read-only.
const snap = snapstate({count: 0})
snap.state.count += 1
snap.readable.count += 1
- we can pass the
readable
around to the parts of our application that should only have read-access to the state (like our components).
- but components will also need access to
track
, subscribe
, and the rest of it —
- so snapstate has a handy
restricted
function, which makes a read-only version of a snapstate.
import {snapstate, restricted} from "@chasemoskal/snapstate"
const snap = snapstate({count: 0})
myFrontendComponents({
snap: restricted(snap),
})
- it's easy to formalize actions with snapstate.
const snap = snapstate({count: 0})
const actions = {
increment() {
snap.state.count += 1
},
}
myFrontendComponents({
snap: restricted(snap),
actions,
})
- note:
snap.state
and snap.writable
are aliases for each other.
👁️ subscribe to any change in the whole state tree
✋ untrack and unsubscribe all
- delete all trackers
snap.untrackAll()
- delete all subscriptions
snap.unsubscribeAll()
⛹️ debouncing and waiting
- tracking and subscription callbacks are debounced.
this prevents consecutive changes from firing more callbacks than necessary.
const snap = snapstate({count: 0})
snap.track(({count}) => console.log(count))
snap.state.count += 1
snap.state.count += 1
snap.state.count += 1
- but be advised — this might mean you have to wait before seeing the effects of your callbacks
const snap = snapstate({count: 0})
let called = false
snap.subscribe(() => called = true)
snap.state.count += 1
console.log(called)
await snap.wait()
♻️ circular-safety
- you are prevented from writing to state while reacting to it.
const snap = snapstate({count: 0})
snap.track(state => {
state.count += 1
})
- you might think you're clever, and could outsmart snapstate. you'd be wrong!
snap.track(({count}) => {
snap.state.count += 1
})
- as we've established, you can't make circular references in
track
callbacks.
- you also can't make circles with track reactions.
snap.track(
({count}) => ({count}),
() => {
snap.state.count += 1
},
)
snap.state.count += 1
await snap.wait()
- and you can't make circles in subscriptions, either.
snap.subscribe(() => snap.state.count += 1)
snap.state.count += 1
await snap.wait()
- you can catch these async errors on the
snap.wait()
promise.
✂️ substate: carve your state into subsections
- it's awkward to pass your whole application state to every little part of your app.
- so you can snip off chunks, to pass along to the components that need it.
import {snapstate, substate} from "@chasemoskal/snapstate"
const snap = snapstate({
outerCount: 1,
coolgroup: {
innerCount: 2,
}
})
const coolgroup = substate(snap, tree => tree.coolgroup)
console.log(coolgroup.state.innerCount)
coolgroup.track(state => console.log(state.innerCount))
coolgroup.state.innerCount += 1
await coolgroup.wait()
- a substate's
subscribe
function only listens to its subsection of the state.
- a substate's
untrackAll
function only applies to tracking called on the subsection.
- a substate's
unsubscribeAll
function only applies to subscriptions called on the subsection.
- substates can also be
restricted
.
const restrictedCoolgroup = restricted(coolgroup)
restrictedCoolgroup.state.innerCount += 1
👨⚖️ super-strict typescript readonly
- introducing
snap.readonly
. it's snap.readable
's strict and demanding mother-in-law.
readonly
literally is readable
, but with more strict typescript typing.
- you see, typescript is extremely strict about its typescript "readonly" properties.
so much so, that it's very painful to use typescript "readonly" structures throughout your app.
- for this reason, snapstate provides
snap.readable
by default, which will throw errors only at runtime when you're being naughty attempting to write properties there — but the typescript compiler doesn't complain with readable
.
- if your shirt is fully tucked-in, you can use
snap.readonly
to produce compile-time typescript errors.
- anywhere you find a
readable
(for example in track and subscribe callbacks), you could set its type to Read<typeof readable>
to make typescript super strict about it.
📜 beware of arrays, maps, and other fancy objects
- snapstate only tracks changes when properties are set on plain objects.
- what this means, is that methods like
array.push
aren't visible to snapstate.
const snap = snapstate({myArray: []})
snap.state.myArray.push("hello")
- to update an array, we must wholly replace it:
snap.state.myArray = [...snap.state.myArray, "hello"]
- this also means that the properties on any class instances won't be tracked.
const snap = snapstate({
date: new Date(),
myCoolObject: new MyCoolObject(),
})
- this is an entirely survivable state of affairs, but we may eventually do the work to implement special handling for arrays, maps, sets, and other common objects. (contributions welcome!)
🧬 using proxies in your state, if you must
- snapstate doesn't like proxies in the state, so it destroys them on-sight (by making object copies).
- this is to prevent circularity issues, since snapstate's readables are made of proxies.
- if you'd like to specifically allow a particular proxy, you can convince snapstate to allow it into the state tree, by having your proxy return
true
when symbolToAllowProxyIntoState
is accessed.
- snapstate will check for this symbol whenever it ingests objects into the state.
- here's an example:
import {snapstate, symbolToAllowProxyIntoState} from "@chasemoskal/snapstate"
const snap = snapstate({
proxy: new Proxy({}, {
get(t, property) {
if (property === symbolToAllowProxyIntoState)
return true
else if (property === "hello")
return "world!"
},
})
})
console.log(snap.state.proxy.hello)
💖 made with open source love
mit licensed.
please consider contributing by opening issues or pull requests.
// chase