Skip to content

Instantly share code, notes, and snippets.

@beorn
Last active November 11, 2024 07:18
Show Gist options
  • Save beorn/2726d3d3ffe661bb1c3666e2da51c787 to your computer and use it in GitHub Desktop.
Save beorn/2726d3d3ffe661bb1c3666e2da51c787 to your computer and use it in GitHub Desktop.
Brainstorming different kinds of state mgmt APIs
// NOTE: I did use ChatGPT for help fleshing this out
import React from "react"
// Global variable to track current computation for dependency tracking
let currentComputation: ComputedObservable<any> | null = null
// Observable class to handle state changes and subscriptions
class Observable<T> {
#value: T
#subs = new Set<(value: T) => void>()
constructor(value: T) {
this.#value = value
}
get value(): T {
if (currentComputation) currentComputation.deps.add(this)
return this.#value
}
set value(newValue: T) {
if (!Object.is(this.#value, newValue)) {
this.#value = newValue
this.#notify()
}
}
subscribe(callback: (value: T) => void): () => void {
this.#subs.add(callback)
return () => void this.#subs.delete(callback)
}
#notify() {
for (const callback of this.#subs) callback(this.#value)
}
}
// ComputedObservable class to handle computed properties with dependencies
class ComputedObservable<T> {
#computeFn: () => T
#value!: T
deps = new Set<Observable<any>>()
#depUnsubs = new Map<Observable<any>, () => void>()
#subs = new Set<(value: T) => void>()
#isComputing = false
#pendingComputation = false
constructor(computeFn: () => T) {
this.#computeFn = computeFn
this.#computeValue()
}
#computeValue() {
if (this.#isComputing) return
this.#isComputing = true
try {
const oldValue = this.#value
this.#clearDeps()
currentComputation = this
const newValue = this.#computeFn()
currentComputation = null
if (!Object.is(oldValue, newValue)) {
this.#value = newValue
for (const callback of this.#subs) callback(this.#value)
}
this.#setupDeps()
} finally {
this.#isComputing = false
this.#pendingComputation = false
}
}
#clearDeps() {
for (const unsub of this.#depUnsubs.values()) unsub()
this.deps.clear()
this.#depUnsubs.clear()
}
#setupDeps() {
for (const dep of this.deps) {
const unsub = dep.subscribe(() => this.#depChanged())
this.#depUnsubs.set(dep, unsub)
}
}
get value(): T {
if (currentComputation && currentComputation !== this) {
currentComputation.deps.add(this as any)
}
return this.#value
}
#depChanged() {
if (!this.#pendingComputation) {
this.#pendingComputation = true
Promise.resolve().then(() => {
if (this.#pendingComputation) this.#computeValue()
})
}
}
subscribe(callback: (value: T) => void): () => void {
this.#subs.add(callback)
return () => void this.#subs.delete(callback)
}
}
// Add type helper for store definition
type Store<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? Observable<T[K]>
: T[K] extends { get: () => any }
? ComputedObservable<ReturnType<T[K]["get"]>>
: Observable<T[K]>
}
// Update createStore to use type inference
export function createStore<T extends object>(initialState: T): Store<T> {
const store: any = {}
const observables: { [key: string]: Observable<any> } = {}
const computedObservables: { [key: string]: ComputedObservable<any> } = {}
// Proxy to handle 'this' binding and property access within computed properties and methods
const storeProxy = new Proxy(store, {
get(target, prop) {
// Access observables directly without going through .value
if (prop in observables) {
return observables[prop as string].value
} else if (prop in computedObservables) {
return computedObservables[prop as string].value
} else if (prop in target) {
return target[prop as string]
}
return undefined
},
set(_target, prop, value) {
if (prop in observables) {
observables[prop as string].value = value
return true
}
return false
},
})
const propertyDescriptors: Record<keyof T, PropertyDescriptor> =
Object.getOwnPropertyDescriptors(initialState)
// Process initial state properties
for (const key in propertyDescriptors) {
const descriptor = propertyDescriptors[key]
const value = initialState[key]
if (typeof descriptor.get === "function") {
// Computed property
// Will be processed after creating the proxy
} else if (typeof value === "function") {
// Method
const method = value
const observableMethod = new Observable(function (...args: any[]) {
return method.apply(storeProxy, args)
})
observables[key] = observableMethod
Object.defineProperty(store, key, {
get() {
return observableMethod
},
})
} else {
// Observable property
observables[key] = new Observable(value)
Object.defineProperty(store, key, {
get() {
return observables[key]
},
set(value) {
observables[key].value = value
},
})
}
}
// Now process computed properties
for (const key in propertyDescriptors) {
const descriptor = propertyDescriptors[key]
if (typeof descriptor.get === "function") {
const computeFn = function () {
return descriptor.get!.call(storeProxy)
}
const computedObservable = new ComputedObservable(computeFn)
computedObservables[key] = computedObservable
Object.defineProperty(store, key, {
get() {
return computedObservable
},
})
}
}
return store as Store<T>
}
// useStoreValue hook to get the current value and subscribe to changes
function useStoreValue<T>(
observable: Observable<T> | ComputedObservable<T>
): T {
const [, forceUpdate] = React.useReducer((x) => x + 1, 0)
React.useEffect(() => observable.subscribe(forceUpdate), [observable])
return observable.value
}
// ---
// Example usage:
// Create the store
const store = createStore({
a: 1,
b: 2,
get sum() {
return this.a + this.b
},
get sum2x() {
return this.sum * 2
},
addA(n: number) {
this.a = this.a + this.sum + n
},
})
// In a React component
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 9</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