Reactive System Deep Dive
Deep dive into ZenGrid's signal-based reactive system, computed values, and effects.
ZenGrid uses a signal-based reactive system built on @preact/signals-core for state management and automatic updates. This system powers the grid’s efficient rendering and update mechanisms.
Overview
The reactive system provides:
- Signals: Reactive values that notify subscribers when changed
- Computed: Derived values that automatically update when dependencies change
- Effects: Side effects that run when dependencies change
- Async Computed: Asynchronous computed values with loading states
- Phases: Execution order control for predictable updates
ZenGrid’s reactive system is inspired by SolidJS signals and uses Preact Signals under the hood for maximum performance and minimal overhead.
GridStore API
The GridStore is the central reactive state container for a grid instance.
extend()
Create a new signal in the store:
import type { GridStore } from '@zengrid/core';
// Create a signalstore.extend('rowCount', 100, 'my-plugin', 10);
// Parameters:// - key: string - unique identifier// - initial: any - initial value// - owner: string - plugin or component name (for cleanup)// - phase: number - execution order (0-130)computed()
Create a computed signal that automatically updates:
// Computed value based on other signalsstore.computed( 'visibleRowCount', () => { const rowCount = store.get('rowCount').value; const filters = store.get('filters').value; return applyFilters(rowCount, filters); }, 'my-plugin', 20);
// The computed value updates automatically when dependencies changeeffect()
Create a side effect that runs when dependencies change:
// Effect for loggingstore.effect( 'log-row-count', () => { const rowCount = store.get('rowCount').value; console.log('Row count changed:', rowCount); }, 'my-plugin', 100);
// Effect with cleanupstore.effect( 'setup-listener', () => { const element = document.getElementById('grid'); const handler = () => console.log('clicked'); element?.addEventListener('click', handler);
// Cleanup function return () => { element?.removeEventListener('click', handler); }; }, 'my-plugin', 100);Effects that return a function will call that function for cleanup when the effect is re-run or when the store is disposed.
asyncComputed()
Create an asynchronous computed value:
import type { AsyncState } from '@zengrid/core';
// Async computed for fetching datastore.asyncComputed( 'userData', async () => { const userId = store.get('selectedUserId').value; const response = await fetch(`/api/users/${userId}`); return response.json(); }, 'my-plugin', 30, { initialValue: null, debounce: 300, // Debounce requests by 300ms });
// Access the async stateconst userData = store.get('userData').value as AsyncState<User>;
console.log(userData.loading); // booleanconsole.log(userData.error); // Error | nullconsole.log(userData.data); // User | nullget() and set()
Read and write signal values:
// Get signal valueconst rowCount = store.get('rowCount').value;
// Set signal valuestore.set('rowCount', 200);
// Batch multiple updatesstore.set('rowCount', 200);store.set('colCount', 10);// Effects run once after all updatesMultiple set() calls are automatically batched. Effects run once after all updates in a batch complete.
Signal Phases
Phases control the execution order of computed values and effects. Lower phases run before higher phases.
Phase Ranges
// Phase 0-10: Core state (row count, column definitions)store.extend('rowCount', 100, 'core', 0);store.extend('columns', [], 'core', 0);
// Phase 10-20: Data transformations (sorting, filtering)store.computed('sortedData', () => { /* ... */ }, 'sort-plugin', 10);store.computed('filteredData', () => { /* ... */ }, 'filter-plugin', 20);
// Phase 20-40: Layout calculationsstore.computed('columnWidths', () => { /* ... */ }, 'layout', 30);
// Phase 40-100: Renderingstore.computed('visibleRows', () => { /* ... */ }, 'viewport', 40);store.effect('render-cells', () => { /* ... */ }, 'renderer', 50);
// Phase 100-130: Side effects, loggingstore.effect('log-changes', () => { /* ... */ }, 'logger', 110);Phases ensure predictable update order. A computed value at phase 20 can safely depend on signals from phase 10, but not from phase 30.
Why Phases Matter
// Without proper phases (unpredictable order):store.computed('total', () => { return store.get('subtotal').value + store.get('tax').value;}, 'plugin', 0);
store.computed('tax', () => { return store.get('subtotal').value * 0.1;}, 'plugin', 0); // Same phase - order undefined!
// With proper phases (predictable order):store.computed('subtotal', () => { return calculateSubtotal();}, 'plugin', 10); // Runs first
store.computed('tax', () => { return store.get('subtotal').value * 0.1;}, 'plugin', 20); // Runs second
store.computed('total', () => { return store.get('subtotal').value + store.get('tax').value;}, 'plugin', 30); // Runs thirdEffect Batching
Effects are batched and run via requestAnimationFrame for optimal performance:
// Multiple updates in sequencestore.set('rowCount', 100);store.set('colCount', 10);store.set('filterText', 'search');
// All effects run once after all updates// in the next animation frameManual Flush for Testing
import { flushEffects } from '@zengrid/core';
// In tests, flush effects synchronouslystore.set('rowCount', 100);flushEffects(); // Effects run immediately
expect(renderCount).toBe(1);Use flushEffects() in unit tests to make effects run synchronously. This avoids timing issues with async effect execution.
StoreKeys Type
Use TypeScript types for type-safe store access:
import type { GridStore, StoreKeys } from '@zengrid/core';
// Define store keysinterface MyStoreKeys extends StoreKeys { rowCount: number; colCount: number; filterText: string; sortedData: readonly Row[];}
// Type-safe storeconst store: GridStore<MyStoreKeys> = createStore();
// TypeScript knows the typesconst rowCount: number = store.get('rowCount').value;const sortedData: readonly Row[] = store.get('sortedData').value;
// TypeScript error: Property 'foo' does not exist// store.get('foo');WrappedSignal and WrappedComputed
Signals are wrapped for consistency:
import type { WrappedSignal, WrappedComputed } from '@zengrid/core';
// WrappedSignal<T> - mutable signalconst rowCountSignal: WrappedSignal<number> = store.get('rowCount');rowCountSignal.value; // number (read)rowCountSignal.value = 200; // write
// WrappedComputed<T> - read-only computedconst visibleRowsComputed: WrappedComputed<number> = store.get('visibleRows');visibleRowsComputed.value; // number (read-only)// visibleRowsComputed.value = 100; // Error: read-only!WrappedSignal allows reading and writing, while WrappedComputed is read-only. This distinction helps prevent accidental mutations of computed values.
AsyncState Type
Async computed values have a specific state structure:
import type { AsyncState } from '@zengrid/core';
interface User { id: number; name: string;}
// Async computed valueconst userState = store.get('userData').value as AsyncState<User>;
// Check stateif (userState.loading) { console.log('Loading user data...');}
if (userState.error) { console.error('Error loading user:', userState.error);}
if (userState.data) { console.log('User loaded:', userState.data.name);}
// All statestype AsyncState<T> = { loading: boolean; error: Error | null; data: T | null;};Debug Tools
Debug the reactive dependency graph:
// Get dependency graphconst graph = store.debugGraph();
console.log('Signals:', graph.signals);console.log('Computed:', graph.computed);console.log('Effects:', graph.effects);console.log('Dependencies:', graph.dependencies);
// Example output:// {// signals: ['rowCount', 'colCount', 'filterText'],// computed: ['filteredData', 'sortedData', 'visibleRows'],// effects: ['render-cells', 'update-scrollbar'],// dependencies: {// 'filteredData': ['rowCount', 'filterText'],// 'sortedData': ['filteredData', 'sortColumn'],// 'render-cells': ['visibleRows', 'colCount']// }// }Use store.debugGraph() to visualize the reactive dependency graph. This is invaluable for debugging complex computed value chains and understanding update order.
Cleanup and Disposal
Cleanup signals and effects when no longer needed:
// Dispose specific plugin's signalsstore.disposePlugin('my-plugin');
// Dispose entire storestore.dispose();Complete Example
import { createStore } from '@zengrid/core';import type { GridStore, StoreKeys, AsyncState } from '@zengrid/core';
// Define store schemainterface MyStoreKeys extends StoreKeys { // Core state rowCount: number; filterText: string; sortColumn: string | null;
// Computed filteredRowCount: number; sortedIndices: readonly number[];
// Async userData: AsyncState<User[]>;}
// Create storeconst store: GridStore<MyStoreKeys> = createStore();
// Core signalsstore.extend('rowCount', 1000, 'core', 0);store.extend('filterText', '', 'core', 0);store.extend('sortColumn', null, 'core', 0);
// Filtering computedstore.computed( 'filteredRowCount', () => { const rowCount = store.get('rowCount').value; const filterText = store.get('filterText').value;
if (!filterText) return rowCount;
// Apply filter logic return Math.floor(rowCount * 0.7); // Example }, 'filter-plugin', 10);
// Sorting computedstore.computed( 'sortedIndices', () => { const rowCount = store.get('filteredRowCount').value; const sortColumn = store.get('sortColumn').value;
if (!sortColumn) { return Array.from({ length: rowCount }, (_, i) => i); }
// Apply sort logic return Array.from({ length: rowCount }, (_, i) => i).reverse(); }, 'sort-plugin', 20);
// Async data fetchingstore.asyncComputed( 'userData', async () => { const response = await fetch('/api/users'); return response.json(); }, 'data-plugin', 30, { initialValue: null, debounce: 500, });
// Rendering effectstore.effect( 'render', () => { const sortedIndices = store.get('sortedIndices').value; const userData = store.get('userData').value;
console.log('Rendering', sortedIndices.length, 'rows');
if (userData.data) { console.log('User data available:', userData.data.length, 'users'); } }, 'renderer', 100);
// Update statestore.set('filterText', 'search');store.set('sortColumn', 'name');
// Debugconsole.log('Dependency graph:', store.debugGraph());
// Cleanupstore.dispose();The reactive system automatically tracks dependencies. You don’t need to manually declare which signals a computed value depends on - it’s tracked automatically during execution.
Best Practices
1. Use Appropriate Phases
Place signals in the correct phase based on their role in the update cycle.
2. Keep Computed Functions Pure
Computed functions should be pure - no side effects, only transformations.
// Good: Pure computationstore.computed('total', () => { return store.get('a').value + store.get('b').value;}, 'plugin', 10);
// Bad: Side effect in computedstore.computed('total', () => { console.log('Computing total'); // Side effect! return store.get('a').value + store.get('b').value;}, 'plugin', 10);3. Use Effects for Side Effects
Put side effects (logging, DOM updates, network requests) in effects, not computed values.
4. Batch Updates
Group related updates together to minimize effect re-runs.
// Good: Batched updatesstore.set('rowCount', 100);store.set('colCount', 10);// Effects run once
// Less efficient: Sequential updatesstore.set('rowCount', 100);await nextFrame();store.set('colCount', 10);// Effects run twice5. Clean Up Resources
Return cleanup functions from effects that create resources.
Performance Characteristics
- Signal read: O(1)
- Signal write: O(1) + batched effect scheduling
- Computed: Memoized, only recomputes when dependencies change
- Effect: Runs asynchronously via
requestAnimationFrame - Memory: Minimal overhead per signal (~40 bytes)
Next Steps
- Learn about plugin architecture that uses the reactive system
- Explore performance optimization with reactive patterns
- Read about custom computed strategies