Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active June 9, 2020 10:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save loilo/95d080de1ae482cec49ca8e0438c51fc to your computer and use it in GitHub Desktop.
Save loilo/95d080de1ae482cec49ca8e0438c51fc to your computer and use it in GitHub Desktop.
Not That Tiny Reactive Store

Not That Tiny Reactive Store

Not as tiny as the Tiny Reactive Store anyway.

This pretty small script (900b/700b minzipped with/without decorators) is a reactive store implementation with dependency tracking instead of explicit dependency declaration.

import { value, computed } from 'store.ts'

const bytes = value(4300)
const kbNum = computed(() => bytes.value / 1024)
const kbStr = computed(() => `${kbNum.value.toFixed(2)} KB`)

console.log(bytes.value, kbNum.value, kbStr.value)
// 4300, 4.19921875, "4.20 KB"

bytes.value = 5120
console.log(bytes.value, kbNum.value, kbStr.value)
// 5120, 5, "5.00 KB"

This design also allows for offering decorators for TypeScript classes. Here's the example from above in a class:

import { reactive } from 'store.ts'

class Store {
  @reactive
  bytes = 4300
  
  @reactive
  get kbNum() {
    return this.bytes / 1024
  }

  @reactive
  get kbStr() {
    return `${this.kbNum.toFixed(2)} KB`
  }
}

const store = new Store()
console.log(store.bytes, store.kbNum, store.kbStr)
// 4300, 4.19921875, "4.20 KB"

store.bytes = 5120
console.log(store.bytes, store.kbNum, store.kbStr)
// 5120, 5, "5.00 KB"
type Unwatcher = () => void
type Watcher<T> = (newValue: T, oldValue: T) => void
type WatchMethod<T> = (callback: Watcher<T>) => Unwatcher
type Value<T> = { watch: WatchMethod<T>; value: T }
type Computed<T> = { watch: WatchMethod<T>; readonly value: T }
type Observable<T> = Value<T> | Computed<T>
/**
* Simple pub/sub implementation
* Returns a destructurable array of of functions:
* [publish(...payload), subscribe(callback), hasSubscribers()]
*/
function pubsub<T extends any[] = any>() {
const callbacks = [] as ((...args: T) => void)[]
return [
(...args: T) => callbacks.forEach(callback => callback(...args)),
(callback: (...args: T) => void) => {
callbacks.push(callback)
return () => {
if (callbacks.includes(callback)) {
callbacks.splice(callbacks.indexOf(callback), 1)
}
}
},
() => callbacks.length > 0
] as const
}
/**
* Handle tracking
*/
class TrackingStack<T = any> {
private stack: Set<Observable<T>>[] = []
public get isEnabled() {
return this.stack.length > 0
}
private get currentDependencies() {
return this.stack[this.stack.length - 1]
}
public addDependency(value: Observable<T>) {
if (!this.isEnabled) {
throw new Error('Trying to add dependency outside tracking phase')
}
this.currentDependencies.add(value)
}
public startLayer() {
this.stack.push(new Set())
}
public finishLayer() {
if (!this.isEnabled) {
throw new Error(
'Trying to finish dependency layer outside tracking phase'
)
}
return this.stack.pop()!
}
}
// The global tracking stack
const tracking = new TrackingStack()
/**
* Create a plain reactive value
*
* @param init The initial value
*
* @example
* const number = value(5)
* number.value // Get value, returns 5
* number.value = 10 // Set value to 10
*
* @example
* const number = value(5)
* const unwatch = number.watch((newValue, oldValue) =>
* console.log('number changed from %o to %o', oldValue, newValue)
* )
*
* number.value = 10 // Outputs "number changed from 5 to 10"
*
* unwatch() // Removes the watcher
* number.value = 10 // Outputs nothing
*/
export function value<T>(init: T): Value<T> {
let current = init
const [publish, subscribe] = pubsub<[T, T]>()
const observable = {
get value() {
if (tracking.isEnabled) {
tracking.addDependency(observable)
}
return current
},
set value(newValue) {
const oldValue = current
if (newValue !== oldValue) {
current = newValue
publish(newValue, oldValue)
}
},
watch(callback: Watcher<T>) {
const unwatch = subscribe((...args) => {
callback(...args)
})
return () => {
unwatch()
}
}
}
return observable
}
/**
* The cache that handles invalidating computed properties
*/
class ComputedCache<T, U = any> {
private wasFilled = false
private filled = false
private value?: T
private unwatchers: Unwatcher[] = []
constructor(private bustCallback: () => void) {}
public isHit() {
return this.filled
}
public wasHit() {
return this.wasFilled
}
public getValue() {
return this.value
}
public update(value: T, dependencies: Set<Observable<U>>) {
this.bust()
dependencies.forEach(dependency => {
const unwatch = dependency.watch((v, o) => {
this.bust()
})
this.unwatchers.push(() => {
unwatch()
})
})
this.value = value
this.filled = true
this.wasFilled = true
}
public bust() {
if (!this.isHit()) {
return
}
this.filled = false
this.unwatchers.forEach(unwatcher => unwatcher())
this.unwatchers.splice(0)
this.bustCallback()
}
}
// Using the global Reflect actually is a hack to prevent TypeScript from
// removing raw property access
const accessProp = Reflect
? Reflect.get
: (object: any, prop: string) => object[prop]
/**
* Create a computed reactive value
*
* @param generator The function that derives reactive values from other reactive values
*
* @example
* const number = value(5)
* const square = computed(() => number.value ** 2)
*
* square.value // Get computed value, returns 25
* number.value = 10 // Set value to 10
* square.value // Get computed value, returns 100
*
* @example
* See 2nd example of `value()` for watching
*
*/
export function computed<T>(generator: () => T): Computed<T> {
const cache = new ComputedCache<T>(() => {
if (hasSubscribers()) {
accessProp(observable, 'value')
}
})
const [publish, subscribe, hasSubscribers] = pubsub<[T, T]>()
const observable = {
get value() {
if (cache.isHit()) {
if (tracking.isEnabled) {
tracking.addDependency(observable)
}
return cache.getValue()!
}
if (tracking.isEnabled) {
tracking.addDependency(observable)
}
tracking.startLayer()
const oldValue = cache.getValue()!
const newValue = generator()
const cachedDependencies = tracking.finishLayer()
cache.update(newValue, cachedDependencies)
if (oldValue !== newValue) {
publish(newValue, oldValue)
}
return newValue
},
watch(callback: Watcher<T>) {
// If not evaluated yet, evaluate now
if (!cache.wasHit()) {
accessProp(observable, 'value')
}
const unwatch = subscribe(callback)
return () => {
unwatch()
}
}
}
return observable
}
/**
* Decorate a class property as a plain reactive value by replacing it with a getter/setter
*/
function decorateValue<T>(target: any, key: string) {
const observable = value(target[key])
Object.defineProperty(target, key, {
configurable: true,
get: () => observable.value,
set(value) {
observable.value = value
}
})
}
/**
* Decorate a class getter method as a computed reactive value
*/
function decorateComputed(target: any, key: string) {
const descriptor = Object.getOwnPropertyDescriptor(target, key)
if (!descriptor || typeof descriptor.get !== 'function') {
throw new Error(
`@computed can only be applied to getters, ${key} is not a getter`
)
}
if (!descriptor.configurable) {
throw new Error(
`@computed can not be applied to non-configurable getter "${key}"`
)
}
const store = computed(descriptor.get)
Object.defineProperty(target, key, {
configurable: true,
get: () => store.value
})
}
/**
* Decorate a class member, removing the need to explicitely call .value
* However, this removes their ability to be watched
*
* @example
* class Store {
* @reactive
* number = 5
*
* @reactive
* get square() {
* return this.reactive ** 2
* }
* }
*
* const store = new Store()
* store.number // yields 5
* store.square // yields 25
* store.number = 10
* store.square // yields 100
*/
export function reactive(target: any, key: string) {
const descriptor = Object.getOwnPropertyDescriptor(target, key)
if (descriptor && typeof descriptor.get === 'function') {
decorateComputed(target, key)
} else {
decorateValue(target, key)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment