Skip to content

Instantly share code, notes, and snippets.

@beorn
Last active November 12, 2024 21:04
Show Gist options
  • Save beorn/83699eb4d4c8a41073290a962648997a to your computer and use it in GitHub Desktop.
Save beorn/83699eb4d4c8a41073290a962648997a to your computer and use it in GitHub Desktop.
Another experiment using (state) => instead of 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
}
}
}
// Utility types
type ComputedState<T> = {
[K in keyof T]: T[K] extends { get: () => infer R } ? R : T[K]
}
// Transform store shape to implementation shape
type WithState<T> = {
[K in keyof T]: T[K] extends (...args: infer P) => any
? (...args: P) => (state: ComputedState<T>) => ReturnType<T[K]>
: T[K] extends { get: () => infer R }
? { get: () => (state: ComputedState<T>) => R }
: T[K]
}
// Store type with Observables
type Store<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? Observable<(...args: Parameters<T[K]>) => void>
: Observable<T[K] extends { get: () => infer R } ? R : T[K]>
}
/** Creates a reactive store from a definition object */
export function createStore<T>(definition: WithState<T>): Store<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.value &&
typeof descriptor.value === "object" &&
"get" in descriptor.value
) {
// Handle computed properties defined as objects with get
const getter = descriptor.value.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,
})
// Get the state function and execute it immediately
const stateFn = getter()
return stateFn(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
const method = (...args: any[]) => {
const stateFn = value(...args)
const updates = stateFn(state) 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<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
}
/** Store */
type StoreShape = {
a: number
b: number
addA(n: number): Partial<StoreShape>
sum: { get: () => number }
sum2x: { get: () => number }
}
export const store = createStore<StoreShape>({
a: 1,
b: 2,
addA(n: number) {
return (state) => ({ a: state.a + n })
},
sum: {
get: () => (state) => state.a + state.b,
},
sum2x: {
get: () => (state) => state.sum * 2,
},
})
// Component should now have correct types
export default function MyComponent() {
const a = useStoreValue(store.a) // number
const b = useStoreValue(store.b) // number
const sum = useStoreValue(store.sum) // number
const sum2x = useStoreValue(store.sum2x) // number
const addA = useStoreValue(store.addA) // (n: number) => void
return (
<div>
<h1>Brainstorming 12</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>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment