Skip to content

Instantly share code, notes, and snippets.

@beorn
Last active November 11, 2024 23:52
Show Gist options
  • Save beorn/23c8bee75ac2acd4758ac0d3defe76e7 to your computer and use it in GitHub Desktop.
Save beorn/23c8bee75ac2acd4758ac0d3defe76e7 to your computer and use it in GitHub Desktop.
Another test of reactive state
// NOTE: I did use Cursor/LLMs to help write this
import React from "react"
/** Base class for reactive values that can notify subscribers of changes */
class Observable<T> {
protected _value: T
protected subscribers = new Set<() => void>()
/** Tracks dependencies during computed property evaluation */
static currDeps: Set<Observable<any>> | null = null
constructor(value: T) {
this._value = value
}
/** Get current value and register as dependency if being tracked */
get value(): T {
if (Observable.currDeps) Observable.currDeps.add(this)
return this._value
}
/** Update value and notify subscribers if changed */
set value(newValue: T) {
if (Object.is(this._value, newValue)) return
this._value = newValue
this.subscribers.forEach((fn) => fn())
}
/** Subscribe to value changes, returns cleanup function */
subscribe(fn: () => void): () => void {
this.subscribers.add(fn)
return () => this.subscribers.delete(fn)
}
/** Execute function while collecting Observable dependencies */
static collectDeps<T>(fn: () => T) {
const prevDeps = Observable.currDeps
const deps = new Set<Observable<any>>()
Observable.currDeps = deps
const value = fn()
Observable.currDeps = prevDeps
return { value, deps }
}
}
/** Observable that automatically updates based on other Observable values */
class ComputedObservable<T> extends Observable<T> {
private computeFn: () => T
private cleanup: Array<() => void> = []
private isComputing = false
private isDirty = true
constructor(computeFn: () => T) {
super(undefined as any)
this.computeFn = computeFn
this.recompute()
}
override get value(): T {
if (this.isDirty && !this.isComputing) this.recompute()
if (!this.isComputing && Observable.currDeps) Observable.currDeps.add(this)
return this._value
}
private recompute(): void {
if (this.isComputing) return
this.isComputing = true
try {
this.cleanup.forEach((cleanup) => cleanup())
this.cleanup = []
const { value, deps } = Observable.collectDeps(this.computeFn)
if (!Object.is(this._value, value)) {
this._value = value
this.subscribers.forEach((fn) => fn())
}
deps.forEach((dep) => {
this.cleanup.push(
dep.subscribe(() => {
if (!this.isComputing) {
this.isDirty = true
this.subscribers.forEach((fn) => fn())
}
})
)
})
this.isDirty = false
} finally {
this.isComputing = false
}
}
}
type RawValue = number | string | boolean
type StoreMethod<T> = (this: ComputedState<T>, ...args: any[]) => Partial<T>
type ComputedProperty<T, R> = { get: (this: ComputedState<T>) => R }
type StoreDefinition<T> = {
[K in keyof T]: T[K] extends RawValue
? T[K]
: T[K] extends (...args: any[]) => any
? StoreMethod<T>
: T[K] extends { get: any }
? ComputedProperty<T, ReturnType<T[K]["get"]>>
: never
}
type ComputedState<T> = {
[K in keyof T]: T[K] extends RawValue
? T[K]
: T[K] extends (...args: any[]) => any
? never
: T[K] extends { get: (...args: any[]) => infer R }
? R
: never
}
// Update Store type to handle methods as Observable<Function>
type Store<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? Observable<(...args: Parameters<T[K]>) => void>
: Observable<T[K]>
}
/** Creates a reactive store from a definition object */
export function createStore<T extends Record<string, any>>(
definition: T
): Store<StoreDefinition<T>> {
const store: Record<string, any> = {}
const state = {} as ComputedState<T>
const observables = new Map<keyof T, Observable<any>>()
const computeds = new Map<keyof T, ComputedObservable<any>>()
for (const key in definition) {
const descriptor = Object.getOwnPropertyDescriptor(definition, key)
if (!descriptor) continue
if (descriptor.get) {
const getter = descriptor.get
const computed = new ComputedObservable(() => {
const wrapper = {} as ComputedState<T>
for (const k in store)
if (store[k] && "value" in store[k])
Object.defineProperty(wrapper, k, {
get: () => store[k].value,
enumerable: true,
})
return getter.call(wrapper)
})
computeds.set(key as keyof T, computed)
store[key] = computed
Object.defineProperty(state, key, {
get: () => computed.value,
enumerable: true,
})
} else if (typeof definition[key] !== "function") {
const observable = new Observable(definition[key])
observables.set(key as keyof T, observable)
store[key] = observable
Object.defineProperty(state, key, {
get: () => observable.value,
set: (v) => (observable.value = v),
enumerable: true,
})
}
}
for (const key in definition) {
const value = definition[key]
if (typeof value !== "function" || computeds.has(key as keyof T)) continue
// Wrap method in Observable
const method = (...args: any[]) => {
const updates = value.apply(state, args) as Partial<T>
if (!updates) return
Object.entries(updates).forEach(([key, value]) => {
if (key in state) (state[key as keyof T] as any) = value
})
}
store[key] = new Observable(method)
}
return store as Store<StoreDefinition<T>>
}
/** React hook to subscribe to observable value changes */
function useStoreValue<T>(observable: Observable<T>): T {
const [, forceUpdate] = React.useReducer((x) => x + 1, 0)
React.useEffect(() => observable.subscribe(forceUpdate), [observable])
return observable.value
}
export default function MyComponent() {
const a = useStoreValue(store.a)
const b = useStoreValue(store.b)
const sum = useStoreValue(store.sum)
const sum2x = useStoreValue(store.sum2x)
const addA = useStoreValue(store.addA)
return (
<div>
<h1>Brainstorming 11</h1>
<div>a: {a}</div>
<div>b: {b}</div>
<div>sum: {sum}</div>
<div>sum2x: {sum2x}</div>
<button onClick={() => addA(5)}>Add to A</button>
</div>
)
}
export const store = createStore({
a: 1,
b: 2,
addA(n: number) {
return { a: this.a + n }
},
get sum(): number {
return this.a + this.b
},
get sum2x(): number {
return this.sum * 2
},
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment