[!IMPORTANT] This codebase is deprecated, and has been moved to @figureland/kit.
statekit is a simple toolkit of primitives for building apps and systems driven by data and events. Written from scratch in Typescript with no dependencies.
The main purpose of this project is for learning and research. Hence why it will likely never have a major version release. The hope is that the internals of statekit will be gradually be replaced somewhat if/when TC39 introduce Signals to the Javascript language spec and it becomes mainstream in browsers. That might be a way off yet. It's not intended to be a polyfill as that hasn't been standardised yet.
signal
This is the base reactive primitive.
import { signal } from '@figureland/statekit'
const v = signal(0)
v.set(0) // set value to 0
v.set('m') // ts error
v.on((newValue: number) => {
// ...
})
v.get() // returns 0
signal
The signal provides a use()
method you want to attach other dependencies to the signal. This is useful for managing listeners or other external data sources that feed into the signal.
import { signal } from '@figureland/statekit'
const pointer = signal({ x: 0, y: 0 })
const onMove = (e: PointerEvent) =>
pointer.set({
x: e.clientX,
y: e.clientY
})
const listener = window.addEventListener('pointermove', onMove)
pointer.use(() => window.removeEventListener('pointermove', onMove))
// When pointer is disposed, it will clean up the event listener as well
pointer.dispose()
You can create new signals derived from other signals or any sources that implements the Subscribable
interface. You can use the first argument in an initialiser function. You can wrap this around any other signals, states or reactive objects from this library. It will pick up the dependencies and update automatically whenever they change.
I am a bit torn on this API but it didn't feel worth getting hung up on it. There are standards experts and researchers who are better placed to make that call. It's nice to automatically track
Subscribable
when.get()
is called in the initialiser function. But my personal feeling, for now at least, is that feels too much like magic for me. Maybe there's enough room in the world for both approaches!
import { signal } from '@figureland/statekit'
const x = signal(2)
const y = signal(1)
const pos = signal((get) => ({
x: get(x),
y: get(y)
}))
post.on((newValue: { x: number; y: number }) => {
// ...subscribes to the derived new value,
// updates whenever x or y are updated
})
Note: Be sure to wrap the whole signal dependency e.g.
get(mySignal)
, notget(mySignal.get())
, which will produce errors.
This library encourages you to decide yourself how a signal value has changed. You can do this using a custom equality check function. By default, signal does a basic shallow equality check to see if the value has changed.
import { signal } from '@figureland/statekit'
const num = signal(() => 0, {
equality: (a, b) => a === b
})
If you have an object complex nested state, you can provide your own custom merging function. By default, if the value is an object, it will use a simple (a, b) => ({ ...a, ...b })
merge. More complex object-like variables such as Map
, Set
, or Arrays won't be merged unless you want them to. Something like deepmerge-ts
could be a good way to do that.
import { signal } from '@figureland/statekit'
import customMerge from './custom-merge'
const obj = signal(() => ({ x: 0, y: [{ a: 1, b: '2', c: { x: 1 } }] }), {
merge: customMerge
})
record
This is a helper function that creates a object with a signal in each key. You can subscribe to them as a collection or individually. Although you can store an object in a regular signal()
, this is helpful for a complex stateful object where you might want to subscribe to both the whole object and individual keys.
import { record } from '@figureland/statekit'
const v = record({
arr: [1],
point: {
x: 0,
y: 0
},
value: 'something'
})
// Update the whole object
v.set({ value: 'another' })
v.set({ arr: [2] })
v.set((a) => ({ arr: [...a.arr, 3] }))
// Subscribe to whole object
v.on((newValue: { arr: number[]; point: { x: number; y: number; value: string } }) => {
// ...
})
// Use individual properties
v.key('arr').get() // [1]
v.key('point').get() // { x: 0, y: 0 }
v.key('point') // Signal<{ x: 0, y: 0 }>
v.key('point').on((v: { x: number; y: number }) => {
// ...
})
// Set individual properties
v.key('point').set({ x: 1, y: 2 })
persist
The persist will wrap a signal and persist its value to a storage API. The storage API is supplied as the second argument. This package provides a typedLocalStorage
method that uses superjson to safely store data in LocalStorage (with a wider range of supported types than JSON.stringify()
). If we want to persist in a type-safe way, we need to supply some extra information.
name
provides the path for the storage key. So, for example ['my','example','1']
would produce the key my/example/1
.validate
returns a boolean checking that the value in storage is of the same type as the signal.interval
is a way of throttling the storage of values; useful if you are sending many updates to a signal and don't need to guarantee they are always up to date.import { type PersistenceName, typedLocalStorage } from '@figureland/statekit/typed-local-storage'
import { isNumber } from '@figureland/typekit/guards'
const exampleSignal = signal(() => 0)
persist(
exampleSignal,
typedLocalStorage({
name: ['example', 'signal'],
validate: isNumber,
interval: 1000
})
)
system
to organise a collection of SignalsOften you need to manage multiple signals in one place, disposing of them all together when cleaning up. Mr System comes in handy here.
import { system, signal } from '@figureland/statekit'
const create = () => {
const { use, dispose } = system()
const one = use(signal(() => 0))
const two = use(signal(() => [2]))
const three = use(signal((get) => ({ v: get(one) })))
return {
one,
two,
three,
dispose
}
}
The system also provides a unique
method. This is a basic utility that you can use to generate idempotent signals based on a key. So rather than creating multiple signals that compute the same value, you allow multiple subscriptions to the same source.
import { system, signal } from '@figureland/statekit'
const create = () => {
const { unique, dispose } = system()
const subscribe = (id: string) => unique(id, () => signal(() => getSomething(id)))
return {
dispose,
subscribe
}
}
animated
This is a helper function which provides the raw ingredients to create animated signal values.
There are lots of great UI libraries for motion like Svelte's motion and of course react-spring. Motion One is also amazing. But they are very much based on animating HTML UI, and the animation management tends to happen within UI/framework code. Particularly in the case of React Spring I always struggle with remembering the API which seems to be extremely powerful but (in my opinion) very complicated but which is constantly battling between React's internal rendering and more declarative style of animation. This solution allows you to hoist the animation loop/update logic into separate state, which you could then subscribe to efficiently within your UI code.
Probably it could use the Web Animations API. But this part of the library is intended for a slightly different use case and I would suggest a battle-tested animation library like Motion or GSAP for that.
So the problem that this solved for me was:
Here's a practical scenario: I have a matrix that I'm using to transform a infinite canvas element. The canvas has some zoom controls. So the 'zoom level' is actually is actually hidden away inside the matrix [scale, 0, 0, scale, 0, 0]. If a user clicks the zoom in button, that should be an animated transition so it doesn't feel too jarring. But if a user directly pans the canvas, no animation should be applied as that would feel sluggish and unresponsive. So I want to keep my transform matrix as the source of truth, and then selectively animate or immediately change how the canvas is rendered in UI depending on user interaction.
It doesn't even attempt to solve the same problems that Motion One, React Spring solve which is not hitting the UI framework's internal reactivity system with 60 (or more) updates a second. This is intended for directly animating specific DOM elements or declaratively updating a WebGL/2D canvas.
import { loop, animation, signal } from '@figureland/statekit'
import { easeInOut } from '@figureland/mathkit/easing'
// We create an instance of animation which in simple terms manages a set
// of signals that need to be updated based on a desired FPS
const engine = animation({ fps: 90 })
// You can manually update the engine tick by tick if you like
engine.tick(16)
// A nice lightweight solution is to wrap your engine in a loop() which automatically ticks
// the engine along with a requestAnimationFrame render loop. If there are no active
// animations, the loop pauses.
const { animated, events } = loop(animation({ fps: 60 }), { autoStart: true })
// Create a plain old signal here
const s = signal(() => ({ x: 0, y: 0 }))
// a is an 'animated mirror' of that signal. When that signal changes,
// will automatically tween towards the new target. You'll need to supply
// an interpolate function which tells the animated signal how to
// transition between different states.
// You can also supply an easing function if you want to control the curve
// of the motion.
const a = animated(s, {
duration: 240,
easing: easeInOut,
interpolate: (f, t, a) => lerpVec2(f, f, t, a)
})
// To update the animation, update the source
s.set({ x: 10, y: -10 })
// Over 21.6 frames (@ 90fps) the animated signal
// will be automatically tweened.
// If you want to set the value immediately, just call the
// set() method on the animated signal rather than the source.
// It will finish any active animations immediately and stop
// updating.
a.set({ x: 1, y: 1 })
// Bear in mind it will start animating again if it detects that
// the source signal changes.
// You can also use the animation engine wherever else you might
// need it. One use case is if you have an animated engine which
// is rendering a canvas.
engine.on('tick', (f: number) => {
// f is the delta since the last tick
renderSomething()
})
history
to track a Signal's values over timeThis is a very basic helper which maintains a log of past values of a Subscribable
, alongside the timestamp when they were changed. It was mainly created as a tool for debugging. It could work for an undo/redo history if the value of signal is very basic.
import { history, signal } from '@figureland/statekit'
const x = signal(() => 2)
const h = history(x, { limit: 3 })
x.set(3)
h.get() // [[1714414077814, 2]]
x.set(4)
h.get() // [[1714414077814, 2], [1714414077815, 3]]
x.set(5)
h.get() // [[1714414077814, 2], [1714414077815, 3], [1714414077816, 4]]
// You can revert to the previous version by calling restore() on the history
// with the associated timestamp.
x.get() // 5
h.restore()
x.get() // 4
h.get() // [[1714414077815, 3], [1714414077816, 4], [1714414077817, 5]]
h.restore(-2)
x.get() // 3
h.get() // [[1714414077816, 4], [1714414077817, 5], [1714414077818, 4]]
You might have noticed that this library is very chainable, e.g. you might end up doing:
const mynumber = use(
history(
persist(
engine.animated(
signal(() => 0),
animationOptions
),
persistenceOptions
)
)
)
Be careful though! Firstly my personal opinion is these dense chains of functions are quite hard to read. Also this example won't work because history doesn't return the original signal you provide as an argument, unlike the other methods. It's also best to think about the order of chaining as well. An opinionated alternative:
const { use } = system()
const engine = use(loop(animation({ fps: 60 }), { autoStart: true }))
const myNumber = use(signal(() => 0))
const h = use(history(myNumber, { limit: 5 }))
// You only want to persist the target values to LocalStorage, not the
// intermediate tweened versions from its animated mirror.
use(persist(myNumber, persistenceOptions))
// Keep the animated value separate because it will produce a lot of
// events so you want to use it selectively
const a = use(engine.animated(myNumber, animationOptions))
bun install
bun test
bun run build
This codebase draws on a lot of previous ideas in other projects, like:
You should use those projects! They will make you happy and/or rich. This is a project for learning and building a specific sharp tool that manages state in an opinionated way.