Last active
April 18, 2025 19:41
-
-
Save gcollazo/cd0083dc832909d8fd41a3016dac8510 to your computer and use it in GitHub Desktop.
Lightweight, dependency-tracking reactive state management system
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* signals.ts | |
* | |
* Lightweight, dependency-tracking reactive state management system | |
* inspired by modern reactive programming paradigms. It provides primitives for creating | |
* and managing reactive values that automatically notify subscribers when they change. | |
* | |
* Key components: | |
* - Subscribable: Interface for reactive values that can be observed | |
* - Signal: Core primitive for wrapping a value in a reactive container | |
* - Computed: Derived values that automatically update when their dependencies change | |
* - Helper functions: signal() and computed() factory functions for easier creation | |
* | |
* Why this is useful: | |
* - Enables predictable state management with clear data flow | |
* - Provides automatic dependency tracking, reducing manual state synchronization | |
* - Improves application performance by only updating what needs to change | |
* - Creates a clear separation between state definition and UI updates | |
* - Facilitates building reactive UIs without heavy frameworks | |
* | |
* Example 1: Simple counter with derived state | |
* ``` | |
* // Create a counter | |
* const count = signal(0); | |
* | |
* // Create derived values | |
* const isEven = computed(() => count.value % 2 === 0, [count]); | |
* const squared = computed(() => count.value * count.value, [count]); | |
* | |
* // Subscribe to changes | |
* isEven.subscribe((newValue) => { | |
* console.log(`Is count even? ${newValue}`); | |
* }); | |
* | |
* // Update the original value | |
* count.value = 1; // Logs: "Is count even? false" | |
* count.value = 2; // Logs: "Is count even? true" | |
* ``` | |
* | |
* Example 2: Form validation | |
* ``` | |
* // Create form fields | |
* const username = signal(''); | |
* const password = signal(''); | |
* | |
* // Create validation rules | |
* const isUsernameValid = computed(() => username.value.length >= 3, [username]); | |
* const isPasswordValid = computed(() => password.value.length >= 8, [password]); | |
* const isFormValid = computed( | |
* () => isUsernameValid.value && isPasswordValid.value, | |
* [isUsernameValid, isPasswordValid] | |
* ); | |
* | |
* // Subscribe to form validity changes | |
* isFormValid.subscribe((valid) => { | |
* document.getElementById('submit').disabled = !valid; | |
* }); | |
* | |
* // Form inputs trigger automatic UI updates | |
* username.value = 'bob'; // Submit remains disabled | |
* password.value = 'securepassword123'; // Submit becomes enabled | |
* ``` | |
* | |
* Example 3: Data filtering with multiple dependencies | |
* ``` | |
* // Data source | |
* const items = signal([ | |
* { id: 1, name: 'Apple', category: 'fruit', price: 1.20 }, | |
* { id: 2, name: 'Banana', category: 'fruit', price: 0.50 }, | |
* { id: 3, name: 'Carrot', category: 'vegetable', price: 0.75 } | |
* ]); | |
* | |
* // Filter controls | |
* const categoryFilter = signal('all'); | |
* const maxPrice = signal(999); | |
* | |
* // Filtered list updates whenever any dependency changes | |
* const filteredItems = computed(() => { | |
* return items.value.filter(item => { | |
* const categoryMatch = categoryFilter.value === 'all' || | |
* item.category === categoryFilter.value; | |
* const priceMatch = item.price <= maxPrice.value; | |
* return categoryMatch && priceMatch; | |
* }); | |
* }, [items, categoryFilter, maxPrice]); | |
* | |
* // UI automatically updates when filter criteria change | |
* filteredItems.subscribe((filtered) => { | |
* renderItemList(filtered); | |
* }); | |
* | |
* // Change any dependency to trigger updates | |
* categoryFilter.value = 'fruit'; // Shows only fruits | |
* maxPrice.value = 0.60; // Shows only items ≤ $0.60 | |
* ``` | |
*/ | |
// Define types for subscription management | |
type SignalSubscribeFn<T> = (value: T, prevValue: T) => void; | |
type SignalUnsubscribeFn = () => boolean; | |
// Common interface for reactive values that can be subscribed to | |
export interface Subscribable<T> { | |
// Previous value before the most recent change | |
readonly prevValue: T; | |
// Current value - can be read and possibly written | |
value: T; | |
// Add a callback that runs when the value changes | |
// Returns a function that removes the subscription when called | |
subscribe(callback: SignalSubscribeFn<T>): SignalUnsubscribeFn; | |
// Check if there are any active subscribers | |
hasSubscribers(): boolean; | |
} | |
// Standard reactive value container that notifies subscribers on change | |
export class Signal<T> implements Subscribable<T> { | |
private _value: T; | |
private _prevValue: T; | |
private _subscribers: Set<SignalSubscribeFn<T>> = new Set(); | |
constructor(initialValue: T) { | |
this._value = initialValue; | |
this._prevValue = initialValue; | |
} | |
// Access the previous value | |
get prevValue(): T { | |
return this._prevValue; | |
} | |
// Access the current value | |
get value(): T { | |
return this._value; | |
} | |
// Update the value and notify subscribers if changed | |
set value(newValue: T) { | |
if (Object.is(this._value, newValue)) return; | |
this._prevValue = this._value; | |
this._value = newValue; | |
this._subscribers.forEach((callback) => | |
callback(this._value, this._prevValue), | |
); | |
} | |
// Register a subscriber callback | |
subscribe(callback: SignalSubscribeFn<T>): SignalUnsubscribeFn { | |
this._subscribers.add(callback); | |
return () => { | |
return this._subscribers.delete(callback); | |
}; | |
} | |
// Check if there are any active subscribers | |
hasSubscribers(): boolean { | |
return this._subscribers.size > 0; | |
} | |
} | |
// Derived value that automatically updates when dependencies change | |
export class Computed<T> implements Subscribable<T> { | |
private _value: T; | |
private _prevValue: T; | |
private _subscribers: Set<SignalSubscribeFn<T>> = new Set(); | |
private _deriveFn: () => T; | |
private _dependencies: Array<Subscribable<unknown>>; | |
private _unsubscribes: Array<SignalUnsubscribeFn> = []; | |
private _isObserving: boolean = false; | |
constructor(deriveFn: () => T, dependencies: Array<Subscribable<unknown>>) { | |
this._deriveFn = deriveFn; | |
this._dependencies = dependencies; | |
this._value = this._deriveFn(); | |
this._prevValue = this._value; | |
} | |
// Start observing dependencies when needed | |
private _setupObservers(): void { | |
if (this._isObserving) return; | |
this._dependencies.forEach((dep) => { | |
const unsubscribe = dep.subscribe(() => { | |
this._prevValue = this._value; | |
this._value = this._deriveFn(); | |
this._subscribers.forEach((callback) => | |
callback(this._value, this._prevValue), | |
); | |
}); | |
this._unsubscribes.push(unsubscribe); | |
}); | |
this._isObserving = true; | |
} | |
// Stop observing dependencies when not needed | |
private _teardownObservers(): void { | |
if (!this._isObserving) return; | |
this._unsubscribes.forEach((unsubscribe) => unsubscribe()); | |
this._unsubscribes = []; | |
this._isObserving = false; | |
} | |
// Access the previous value | |
get prevValue(): T { | |
return this._prevValue; | |
} | |
// Access the current value, activating lazy observation if needed | |
get value(): T { | |
// Lazy computation on first access if needed | |
if (!this._isObserving && this._subscribers.size > 0) { | |
this._setupObservers(); | |
} | |
return this._value; | |
} | |
// Prevent direct setting of computed values | |
set value(_: T) { | |
console.warn("Cannot set the value of a computed signal directly"); | |
} | |
// Register a subscriber and start observing dependencies if needed | |
subscribe(callback: SignalSubscribeFn<T>): SignalUnsubscribeFn { | |
this._subscribers.add(callback); | |
// Set up observers if this is the first subscriber | |
if (!this._isObserving && this._subscribers.size === 1) { | |
this._setupObservers(); | |
} | |
return () => { | |
const wasRemoved = this._subscribers.delete(callback); | |
// Tear down if no more subscribers | |
if (wasRemoved && this._subscribers.size === 0) { | |
this._teardownObservers(); | |
} | |
return wasRemoved; | |
}; | |
} | |
// Check if there are any active subscribers | |
hasSubscribers(): boolean { | |
return this._subscribers.size > 0; | |
} | |
} | |
// Factory function to create a new signal | |
export function signal<T>(initialValue: T): Signal<T> { | |
return new Signal<T>(initialValue); | |
} | |
// Factory function to create a computed value | |
export function computed<T>( | |
deriveFn: () => T, | |
dependencies: Array<Subscribable<unknown>>, | |
): Subscribable<T> { | |
return new Computed<T>(deriveFn, dependencies); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment