๐ฎ
snapstate
tiny robust state management
npm install @chasemoskal/snapstate
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.
using snapstate
๐พ
readable
and writable
- the first cool thing about snapstate, is that it separates the
readable
andwritable
access to your state. you'll soon learn why that's rad. - let's create some state.
import {snapstate} from "@chasemoskal/snapstate" const state = snapstate({ count: 0, coolmode: "enabled", })
- we can read the state's properties via
readable
.console.log(state.readable.count) // 0
- but, importantly,
readable
won't let us write properties.state.readable.count += 1 // SnapstateReadonlyError -- no way, bucko!
- instead, we write properties via
writable
.state.writable.count += 1 // this is allowed console.log(state.readable.count) // 1
- this separation is great, because we can pass
readable
to parts of our application that should not be allowed to change the state. this is how we control access. - this makes it easy to formalize actions. it's as easy as giving our action functions access to the
writable
state.function myIncrementAction() { state.writable.count += 1 }
- then we might give our frontend components the
state.readable
, andstate.track
functions. - this makes it easy to achieve a clean uni-directional dataflow for our application's state.
writable
is, itself, also readable.console.log(state.writable.count) // 1
๐ต๏ธ
tracking changes
- we can track changes to the properties we care about.
const state = snapstate({count: 0, coolmode: "enabled"}) state.track(() => { console.log(`count changed: ${state.readable.count}`) // โ๏ธ // snapstate detects this property read, // and will run this tracker function // whenever the property changes. }) // 0 -- runs once initially state.writable.count += 1 // 1 -- automatically runs the relevant tracker functions state.writable.coolmode = "disabled" // ~ nothing is logged to console ~ // our track callback doesn't care about this property
- we can be more pedantic, with a custom tracker, to avoid the initial run.
const state = snapstate({count: 0, coolmode: "enabled"}) state.track( // observer: listen specifically to "count" ({count}) => ({count}), // reaction: responding to changes ({count}) => console.log(`count changed: ${count}`), ) state.writable.count += 1 // 1
- we can also stop tracking things when we want.
const state = snapstate({count: 0, coolmode: "enabled"}) const untrack = state.track(() => console.log(count)) state.writable.count += 1 // 1 untrack() state.writable.count += 1 // *nothing happens*
๐ฃ
nesting? no problem!
- we can nest our state to arbitrary depth.
const state = snapstate({ group1: { group2: { data: "hello!", }, }, })
- we can track changes to properties, or groups.
state.track(readable => console.log(readable.group1.group2.hello)) state.track(readable => console.log(readable.group1))
๐๏ธ
subscribe to any change in the whole state
- your subscriptions will execute whenever any state is changed.
state.subscribe(readable => { console.log("something has changed") })
- of course you can unsubscribe, too.
const unsubscribe = state.subscribe(readable => { console.log("something has changed") }) unsubscribe()
โ
untrack and unsubscribe all
- stop all tracking
state.untrackAll()
- stop all subscribers
state.unsubscribeAll()
โน๏ธ
debouncing and waiting
- the updates that respond to changing state, is debounced.
this prevents consecutive updates from firing more updates than necessary.
because of this, you may have towait
before seeing the effects of your update.const state = snapstate({count: 0}) let called = false state.track(() => { called = true }) state.writable.count += 1 console.log(called) // false -- what the heck!? await state.wait() console.log(called) // true -- oh, okay -- i just had to wait for the debouncer!
โป๏ธ
circular-safety
- you are prevented from writing to state while reacting to it.
- you can't make circles with track observers:
state.track(() => { state.writable.count += 1 }) // SnapstateCircularError -- no way, bucko!
- you can't make circles with track reactions:
state.track( ({count}) => ({count}), () => { state.writable.count += 1 }, ) state.writable.count += 1 await state.wait() // SnapstateCircularError -- thwarted again, buddy!
- and you can't make circles with subscriptions:
state.subscribe(() => state.writable.count += 1) state.writable.count += 1 await state.wait() // SnapstateCircularError -- try again, pal!
- you can catch these async errors on
state.wait()
.
โ๏ธ
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 state = snapstate({ outerCount: 1, coolgroup: { innerCount: 2, } }) const coolgroup = substate(state, tree => tree.coolgroup) // note: coolgroup has no access to "outerCount" console.log(coolgroup.readable.innerCount) // 2 coolgroup.track(readable => console.log(readable.innerCount)) coolgroup.writable.innerCount += 1 await coolgroup.wait() // 3
- 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.
- a substate's
๐จโโ๏ธ
strict readonly
- introducing
state.readonly
. it'sreadable
'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
readonly
properties.
so much so, that it's very painful to usereadonly
structures throughout your app. - for this reason, snapstate provides
state.readable
by default, which will throw errors at runtime if you're being naughty and attempting to write properties there -- but the typescript compiler doesn't complain. - if your shirt is tucked-in,
state.readonly
will produce compile-time typescript errors for you. - anywhere you find a
readable
(for example in track and subscribe callbacks), you could set its type toRead<typeof readable>
to make typescript strict about it.
๐
beware of arrays, maps, and other fancy objects
- snapstate only tracks when properties are written.
- what this means, is that methods like
array.push
aren't visible to snapstate:const state = snapstate({myArray: []}) // bad -- updates will not respond. state.writable.myArray.push("hello")
- to update an array, we must wholly replace it:
// good -- updates will respond. state.writable.myArray = [...state.writable.myArray, "hello"]
- 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 makes object copies of them on-sight.
- this is to prevent circularity issues, since snapstate's readables are proxies.
- if you'd like to specifically allow a proxy, you can convince snapstate to allow it into the state, by having your proxy return
true
whensymbolToAllowProxyIntoState
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 state = snapstate({ proxy: new Proxy({}, { get(t, property) { if (property === symbolToAllowProxyIntoState) return true else if (property === "hello") return "world!" }, }) }) console.log(state.readable.proxy.hello) // "world!"
๐
made with open source love
mit licensed.
please consider contributing by opening issues or pull requests.
// chase