Skip to content

Instantly share code, notes, and snippets.

@gcollazo
Last active April 18, 2025 19:41
Show Gist options
  • Save gcollazo/cd0083dc832909d8fd41a3016dac8510 to your computer and use it in GitHub Desktop.
Save gcollazo/cd0083dc832909d8fd41a3016dac8510 to your computer and use it in GitHub Desktop.
Lightweight, dependency-tracking reactive state management system
/**
* 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