Effects API ๐ โ
Effects are the bridge between reactive signals and side effects in your application. They automatically track signal dependencies and re-run whenever those signals change, enabling everything from DOM updates to data fetching and localStorage synchronization.
Core Concepts โ
What is an Effect? โ
An effect is a function that:
- Runs immediately when created
- Tracks all signals read during its execution
- Re-runs automatically when any tracked signal changes
- Can return a cleanup function that runs before the next execution or when the effect is stopped
How Effects Work โ
- When an effect runs, it sets itself as the
activeEffect - Any signal read during execution adds the effect to its subscribers
- When a signal changes, it queues all its subscribers
- Effects are batched and run in the next microtask
- If an effect returns a function, it's stored as a cleanup handler
$.effect(effectFn) โ
Creates a reactive effect that automatically tracks dependencies and re-runs when they change.
import { $ } from 'sigpro';
const count = $(0);
$.effect(() => {
console.log(`Count is: ${count()}`);
});
// Logs: "Count is: 0"
count(1);
// Logs: "Count is: 1"๐ API Reference โ
| Pattern | Example | Description |
|---|---|---|
| Basic Effect | $.effect(() => console.log(count())) | Run on dependency changes |
| With Cleanup | $.effect(() => { timer = setInterval(...); return () => clearInterval(timer) }) | Return cleanup function |
| Stop Effect | const stop = $.effect(...); stop() | Manually stop an effect |
Effect Object (Internal) โ
| Property/Method | Description |
|---|---|
dependencies | Set of signal subscriber sets this effect belongs to |
cleanupHandlers | Set of cleanup functions to run before next execution |
run() | Executes the effect and tracks dependencies |
stop() | Stops the effect and runs all cleanup handlers |
๐ฏ Basic Examples โ
Console Logging โ
import { $ } from 'sigpro';
const name = $('World');
const count = $(0);
$.effect(() => {
console.log(`Hello ${name()}! Count is ${count()}`);
});
// Logs: "Hello World! Count is 0"
name('John');
// Logs: "Hello John! Count is 0"
count(5);
// Logs: "Hello John! Count is 5"DOM Updates โ
import { $ } from 'sigpro';
const count = $(0);
const element = document.getElementById('counter');
$.effect(() => {
element.textContent = `Count: ${count()}`;
});
// Updates DOM automatically when count changes
count(10); // Element text becomes "Count: 10"Document Title โ
import { $ } from 'sigpro';
const page = $('home');
const unreadCount = $(0);
$.effect(() => {
const base = page() === 'home' ? 'Home' : 'Dashboard';
const unread = unreadCount() > 0 ? ` (${unreadCount()})` : '';
document.title = `${base}${unread} - My App`;
});
page('dashboard'); // Title: "Dashboard - My App"
unreadCount(3); // Title: "Dashboard (3) - My App"๐งน Effects with Cleanup โ
Cleanup functions are essential for managing resources like intervals, event listeners, and subscriptions.
Basic Cleanup โ
import { $ } from 'sigpro';
const userId = $(1);
$.effect(() => {
const id = userId();
console.log(`Setting up timer for user ${id}`);
const timer = setInterval(() => {
console.log(`Polling user ${id}...`);
}, 1000);
// Cleanup runs before next effect execution
return () => {
console.log(`Cleaning up timer for user ${id}`);
clearInterval(timer);
};
});
// Sets up timer for user 1
userId(2);
// Cleans up timer for user 1
// Sets up timer for user 2Event Listener Cleanup โ
import { $ } from 'sigpro';
const isListening = $(false);
$.effect(() => {
if (!isListening()) return;
const handleClick = (e) => {
console.log('Window clicked:', e.clientX, e.clientY);
};
window.addEventListener('click', handleClick);
console.log('Click listener added');
return () => {
window.removeEventListener('click', handleClick);
console.log('Click listener removed');
};
});
isListening(true); // Adds listener
isListening(false); // Removes listener
isListening(true); // Adds listener againWebSocket Connection โ
import { $ } from 'sigpro';
const room = $('general');
const messages = $([]);
$.effect(() => {
const currentRoom = room();
console.log(`Connecting to room: ${currentRoom}`);
const ws = new WebSocket(`wss://chat.example.com/${currentRoom}`);
ws.onmessage = (event) => {
messages([...messages(), JSON.parse(event.data)]);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// Cleanup: close connection when room changes
return () => {
console.log(`Disconnecting from room: ${currentRoom}`);
ws.close();
};
});
room('random'); // Closes 'general' connection, opens 'random'โฑ๏ธ Effect Timing and Batching โ
Microtask Batching โ
Effects are batched using queueMicrotask for optimal performance:
import { $ } from 'sigpro';
const a = $(1);
const b = $(2);
const c = $(3);
$.effect(() => {
console.log('Effect ran with:', a(), b(), c());
});
// Logs immediately: "Effect ran with: 1 2 3"
// Multiple updates in same tick - only one effect run!
a(10);
b(20);
c(30);
// Only logs once: "Effect ran with: 10 20 30"Async Effects โ
Effects can be asynchronous, but be careful with dependency tracking:
import { $ } from 'sigpro';
const userId = $(1);
const userData = $(null);
$.effect(() => {
const id = userId();
console.log(`Fetching user ${id}...`);
// Only id() is tracked (synchronous part)
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => {
// This runs later - no dependency tracking here!
userData(data);
});
});
userId(2); // Triggers effect again, cancels previous fetchEffect with AbortController โ
For proper async cleanup with fetch:
import { $ } from 'sigpro';
const userId = $(1);
const userData = $(null);
const loading = $(false);
$.effect(() => {
const id = userId();
const controller = new AbortController();
loading(true);
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
userData(data);
loading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Fetch error:', err);
loading(false);
}
});
// Cleanup: abort fetch if userId changes before completion
return () => {
controller.abort();
};
});๐จ Advanced Effect Patterns โ
Debounced Effects โ
import { $ } from 'sigpro';
const searchTerm = $('');
const results = $([]);
let debounceTimeout;
$.effect(() => {
const term = searchTerm();
// Clear previous timeout
clearTimeout(debounceTimeout);
// Don't search if term is too short
if (term.length < 3) {
results([]);
return;
}
// Debounce search
debounceTimeout = setTimeout(async () => {
console.log('Searching for:', term);
const data = await fetch(`/api/search?q=${term}`).then(r => r.json());
results(data);
}, 300);
// Cleanup on effect re-run
return () => clearTimeout(debounceTimeout);
});Throttled Effects โ
import { $ } from 'sigpro';
const scrollPosition = $(0);
let lastRun = 0;
let rafId = null;
$.effect(() => {
const pos = scrollPosition();
// Throttle with requestAnimationFrame
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
console.log('Scroll position:', pos);
updateScrollUI(pos);
lastRun = Date.now();
rafId = null;
});
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
});
// Even with many updates, effect runs at most once per frame
for (let i = 0; i < 100; i++) {
scrollPosition(i);
}Conditional Effects โ
import { $ } from 'sigpro';
const isEnabled = $(false);
const value = $(0);
const threshold = $(10);
$.effect(() => {
// Effect only runs when isEnabled is true
if (!isEnabled()) return;
console.log(`Monitoring value: ${value()}, threshold: ${threshold()}`);
if (value() > threshold()) {
alert(`Value ${value()} exceeded threshold ${threshold()}!`);
}
});
isEnabled(true); // Effect starts monitoring
value(15); // Triggers alert
isEnabled(false); // Effect stops (still runs, but condition prevents logic)Effect with Multiple Cleanups โ
import { $ } from 'sigpro';
const config = $({ theme: 'light', notifications: true });
$.effect(() => {
const { theme, notifications } = config();
const cleanups = [];
// Setup theme
document.body.className = `theme-${theme}`;
cleanups.push(() => {
document.body.classList.remove(`theme-${theme}`);
});
// Setup notifications
if (notifications) {
const handler = (e) => console.log('Notification:', e.detail);
window.addEventListener('notification', handler);
cleanups.push(() => {
window.removeEventListener('notification', handler);
});
}
// Return combined cleanup
return () => {
cleanups.forEach(cleanup => cleanup());
};
});๐ฏ Effects in Components โ
Component Lifecycle โ
import { $, html } from 'sigpro';
$.component('timer-display', () => {
const seconds = $(0);
// Effect for timer - automatically cleaned up when component unmounts
$.effect(() => {
const interval = setInterval(() => {
seconds(s => s + 1);
}, 1000);
return () => clearInterval(interval);
});
return html`
<div>
<h2>Timer: ${seconds}s</h2>
</div>
`;
});Effects with Props โ
import { $, html } from 'sigpro';
$.component('data-viewer', (props) => {
const data = $(null);
const error = $(null);
// Effect reacts to prop changes
$.effect(() => {
const url = props.url();
if (!url) return;
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data)
.catch(err => {
if (err.name !== 'AbortError') {
error(err.message);
}
});
return () => controller.abort();
});
return html`
<div>
${() => {
if (error()) return html`<div class="error">${error()}</div>`;
if (!data()) return html`<div>Loading...</div>`;
return html`<pre>${JSON.stringify(data(), null, 2)}</pre>`;
}}
</div>
`;
}, ['url']);๐ง Effect Management โ
Stopping Effects โ
import { $ } from 'sigpro';
const count = $(0);
// Start effect
const stopEffect = $.effect(() => {
console.log('Count:', count());
});
count(1); // Logs: "Count: 1"
count(2); // Logs: "Count: 2"
// Stop the effect
stopEffect();
count(3); // No logging - effect is stoppedConditional Effect Stopping โ
import { $ } from 'sigpro';
const isActive = $(true);
const count = $(0);
let currentEffect = null;
$.effect(() => {
if (isActive()) {
// Start or restart the monitoring effect
if (currentEffect) currentEffect();
currentEffect = $.effect(() => {
console.log('Monitoring count:', count());
});
} else {
// Stop monitoring
if (currentEffect) {
currentEffect();
currentEffect = null;
}
}
});Nested Effects โ
import { $ } from 'sigpro';
const user = $({ id: 1, name: 'John' });
const settings = $({ theme: 'dark' });
$.effect(() => {
console.log('User changed:', user().name);
// Nested effect - tracks settings independently
$.effect(() => {
console.log('Settings changed:', settings().theme);
});
// When user changes, the nested effect is recreated
});๐ Real-World Examples โ
Auto-saving Form โ
import { $ } from 'sigpro';
const formData = $({
title: '',
content: '',
tags: []
});
const lastSaved = $(null);
const saveStatus = $('idle'); // 'idle', 'saving', 'saved', 'error'
let saveTimeout;
$.effect(() => {
const data = formData();
// Clear previous timeout
clearTimeout(saveTimeout);
// Don't save empty form
if (!data.title && !data.content) {
saveStatus('idle');
return;
}
saveStatus('saving');
// Debounce save
saveTimeout = setTimeout(async () => {
try {
await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
saveStatus('saved');
lastSaved(new Date());
} catch (error) {
saveStatus('error');
console.error('Auto-save failed:', error);
}
}, 1000);
return () => clearTimeout(saveTimeout);
});
// UI feedback
const statusMessage = $(() => {
const status = saveStatus();
const saved = lastSaved();
if (status === 'saving') return 'Saving...';
if (status === 'error') return 'Save failed';
if (status === 'saved' && saved) {
return `Last saved: ${saved().toLocaleTimeString()}`;
}
return '';
});Real-time Search with Debounce โ
import { $ } from 'sigpro';
const searchInput = $('');
const searchResults = $([]);
const searchStatus = $('idle'); // 'idle', 'searching', 'results', 'no-results', 'error'
let searchTimeout;
let abortController = null;
$.effect(() => {
const query = searchInput().trim();
// Clear previous timeout
clearTimeout(searchTimeout);
// Cancel previous request
if (abortController) {
abortController.abort();
abortController = null;
}
// Don't search for short queries
if (query.length < 2) {
searchResults([]);
searchStatus('idle');
return;
}
searchStatus('searching');
// Debounce search
searchTimeout = setTimeout(async () => {
abortController = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: abortController.signal
});
const data = await response.json();
if (!abortController.signal.aborted) {
searchResults(data);
searchStatus(data.length ? 'results' : 'no-results');
abortController = null;
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
searchStatus('error');
}
}
}, 300);
return () => {
clearTimeout(searchTimeout);
if (abortController) {
abortController.abort();
abortController = null;
}
};
});Analytics Tracking โ
import { $ } from 'sigpro';
// Analytics configuration
const analyticsEnabled = $(true);
const currentPage = $('/');
const userProperties = $({});
// Track page views
$.effect(() => {
if (!analyticsEnabled()) return;
const page = currentPage();
const properties = userProperties();
console.log('Track page view:', page, properties);
// Send to analytics
gtag('config', 'GA-MEASUREMENT-ID', {
page_path: page,
...properties
});
});
// Track user interactions
const trackEvent = (eventName, properties = {}) => {
$.effect(() => {
if (!analyticsEnabled()) return;
console.log('Track event:', eventName, properties);
gtag('event', eventName, properties);
});
};
// Usage
currentPage('/dashboard');
userProperties({ userId: 123, plan: 'premium' });
trackEvent('button_click', { buttonId: 'signup' });Keyboard Shortcuts โ
import { $ } from 'sigpro';
const shortcuts = $({
'ctrl+s': { handler: null, description: 'Save' },
'ctrl+z': { handler: null, description: 'Undo' },
'ctrl+shift+z': { handler: null, description: 'Redo' },
'escape': { handler: null, description: 'Close modal' }
});
const pressedKeys = new Set();
$.effect(() => {
const handleKeyDown = (e) => {
const key = e.key.toLowerCase();
const ctrl = e.ctrlKey ? 'ctrl+' : '';
const shift = e.shiftKey ? 'shift+' : '';
const alt = e.altKey ? 'alt+' : '';
const meta = e.metaKey ? 'meta+' : '';
const combo = `${ctrl}${shift}${alt}${meta}${key}`.replace(/\+$/, '');
const shortcut = shortcuts()[combo];
if (shortcut?.handler) {
e.preventDefault();
shortcut.handler();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
});
// Register shortcuts
shortcuts({
...shortcuts(),
'ctrl+s': {
handler: () => saveDocument(),
description: 'Save document'
},
'ctrl+z': {
handler: () => undo(),
description: 'Undo'
}
});Infinite Scroll โ
import { $ } from 'sigpro';
const posts = $([]);
const page = $(1);
const hasMore = $(true);
const loading = $(false);
let observer = null;
// Load more posts
const loadMore = async () => {
if (loading() || !hasMore()) return;
loading(true);
try {
const response = await fetch(`/api/posts?page=${page()}`);
const newPosts = await response.json();
if (newPosts.length === 0) {
hasMore(false);
} else {
posts([...posts(), ...newPosts]);
page(p => p + 1);
}
} finally {
loading(false);
}
};
// Setup intersection observer for infinite scroll
$.effect(() => {
const sentinel = document.getElementById('sentinel');
if (!sentinel) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading() && hasMore()) {
loadMore();
}
},
{ threshold: 0.1 }
);
observer.observe(sentinel);
return () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
});
// Initial load
loadMore();๐ Performance Considerations โ
| Pattern | Performance Impact | Best Practice |
|---|---|---|
| Multiple signal reads | O(n) per effect | Group related signals |
| Deep object access | Minimal | Use computed signals |
| Large arrays | O(n) for iteration | Memoize with computed |
| Frequent updates | Batched | Let batching work |
| Heavy computations | Blocking | Use Web Workers |
๐ฏ Best Practices โ
1. Keep Effects Focused โ
// โ Avoid doing too much in one effect
$.effect(() => {
updateUI(count()); // UI update
saveToStorage(count()); // Storage
sendAnalytics(count()); // Analytics
validate(count()); // Validation
});
// โ
Split into focused effects
$.effect(() => updateUI(count()));
$.effect(() => saveToStorage(count()));
$.effect(() => sendAnalytics(count()));
$.effect(() => validate(count()));2. Always Clean Up โ
// โ Missing cleanup
$.effect(() => {
const timer = setInterval(() => {}, 1000);
// Memory leak!
});
// โ
Proper cleanup
$.effect(() => {
const timer = setInterval(() => {}, 1000);
return () => clearInterval(timer);
});3. Avoid Writing to Signals in Effects โ
import { $ } from 'sigpro';
const a = $(1);
const b = $(2);
// โ Avoid - can cause loops
$.effect(() => {
a(b()); // Writing to a while reading b
});
// โ
Use computed signals instead
const sum = $(() => a() + b());4. Use Conditional Logic Carefully โ
// โ Condition affects dependency tracking
$.effect(() => {
if (condition()) {
console.log(a()); // Only tracks a when condition is true
}
});
// โ
Track all dependencies explicitly
$.effect(() => {
const cond = condition(); // Track condition
if (cond) {
console.log(a()); // Track a
}
});5. Memoize Expensive Computations โ
import { $ } from 'sigpro';
const items = $([]);
// โ Expensive computation runs on every effect
$.effect(() => {
const total = items().reduce((sum, i) => sum + i.price, 0);
updateTotal(total);
});
// โ
Memoize with computed signal
const total = $(() => items().reduce((sum, i) => sum + i.price, 0));
$.effect(() => updateTotal(total()));๐ Debugging Effects โ
Logging Effect Runs โ
import { $ } from 'sigpro';
const withLogging = (effectFn, name) => {
return $.effect(() => {
console.log(`[${name}] Running...`);
const start = performance.now();
const result = effectFn();
const duration = performance.now() - start;
console.log(`[${name}] Completed in ${duration.toFixed(2)}ms`);
return result;
});
};
// Usage
withLogging(() => {
console.log('Count:', count());
}, 'count-effect');Effect Inspector โ
import { $ } from 'sigpro';
const createEffectInspector = () => {
const effects = new Map();
let id = 0;
const trackedEffect = (fn, name = `effect-${++id}`) => {
const info = {
name,
runs: 0,
lastRun: null,
duration: 0,
dependencies: new Set()
};
const wrapped = () => {
info.runs++;
info.lastRun = new Date();
const start = performance.now();
const result = fn();
info.duration = performance.now() - start;
return result;
};
const stop = $.effect(wrapped);
effects.set(stop, info);
return stop;
};
const getReport = () => {
const report = {};
effects.forEach((info, stop) => {
report[info.name] = {
runs: info.runs,
lastRun: info.lastRun,
avgDuration: info.duration / info.runs
};
});
return report;
};
return { trackedEffect, getReport };
};
// Usage
const inspector = createEffectInspector();
inspector.trackedEffect(() => {
console.log('Count:', count());
}, 'counter-effect');๐ Summary โ
| Feature | Description |
|---|---|
| Automatic Tracking | Dependencies tracked automatically |
| Cleanup Functions | Return function to clean up resources |
| Batch Updates | Multiple changes batched in microtask |
| Manual Stop | Can stop effects with returned function |
| Nested Effects | Effects can contain other effects |
| Auto-cleanup | Effects in pages/components auto-cleaned |
Pro Tip: Effects are the perfect place for side effects like DOM updates, data fetching, and subscriptions. Keep them focused and always clean up resources!