Skip to content

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 โ€‹

  1. When an effect runs, it sets itself as the activeEffect
  2. Any signal read during execution adds the effect to its subscribers
  3. When a signal changes, it queues all its subscribers
  4. Effects are batched and run in the next microtask
  5. 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.

javascript
import { $ } from 'sigpro';

const count = $(0);

$.effect(() => {
  console.log(`Count is: ${count()}`);
});
// Logs: "Count is: 0"

count(1);
// Logs: "Count is: 1"

๐Ÿ“‹ API Reference โ€‹

PatternExampleDescription
Basic Effect$.effect(() => console.log(count()))Run on dependency changes
With Cleanup$.effect(() => { timer = setInterval(...); return () => clearInterval(timer) })Return cleanup function
Stop Effectconst stop = $.effect(...); stop()Manually stop an effect

Effect Object (Internal) โ€‹

Property/MethodDescription
dependenciesSet of signal subscriber sets this effect belongs to
cleanupHandlersSet 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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 2

Event Listener Cleanup โ€‹

javascript
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 again

WebSocket Connection โ€‹

javascript
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:

javascript
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:

javascript
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 fetch

Effect with AbortController โ€‹

For proper async cleanup with fetch:

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 stopped

Conditional Effect Stopping โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

PatternPerformance ImpactBest Practice
Multiple signal readsO(n) per effectGroup related signals
Deep object accessMinimalUse computed signals
Large arraysO(n) for iterationMemoize with computed
Frequent updatesBatchedLet batching work
Heavy computationsBlockingUse Web Workers

๐ŸŽฏ Best Practices โ€‹

1. Keep Effects Focused โ€‹

javascript
// โŒ 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 โ€‹

javascript
// โŒ 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 โ€‹

javascript
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 โ€‹

javascript
// โŒ 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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

javascript
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 โ€‹

FeatureDescription
Automatic TrackingDependencies tracked automatically
Cleanup FunctionsReturn function to clean up resources
Batch UpdatesMultiple changes batched in microtask
Manual StopCan stop effects with returned function
Nested EffectsEffects can contain other effects
Auto-cleanupEffects 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!