Files
sigpro/docs/api/effects.md
2026-03-17 11:40:08 +01:00

22 KiB

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.

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 2

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

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

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

Conditional 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!