Skip to content

Effects ​

While derived values represent reactive values, effects represent reactive functions. Effects run side effects in response to signal changes and evaluate eagerly (unlike derived values which are lazy).

Key Characteristics ​

  • Eager: Runs immediately when created and when dependencies change
  • Side Effects: Designed for side effects (logging, DOM updates, API calls)
  • Disposable: Returns a cleanup function
  • Smart: Uses the same recomputation heuristic as derived values

Creating Effects ​

Basic Usage ​

typescript
import { createSignal, createEffect } from '@signalis/core';

const count = createSignal(0);

createEffect(() => {
  console.log(`The value of count is: ${count.value}`);
});
// Immediately logs: "The value of count is: 0"

count.value = 1; // Logs: "The value of count is: 1"
count.value = 2; // Logs: "The value of count is: 2"

API Reference ​

createEffect(fn: () => void | (() => void)): () => void ​

Creates an effect that runs immediately and whenever its dependencies change.

Parameters:

  • fn: A function that reads reactive values and performs side effects. Can optionally return a cleanup function.

Returns: A disposal function to stop the effect and run cleanup

Effect Lifecycle ​

1. Creation ​

Effect runs immediately upon creation.

2. Dependencies Change ​

Effect reruns whenever tracked dependencies change.

3. Disposal ​

Cleanup function (if provided) runs when effect is disposed or before it reruns.

typescript
import { createSignal, createEffect } from '@signalis/core';

const count = createSignal(0);

const dispose = createEffect(() => {
  console.log(`Count: ${count.value}`);

  return () => {
    console.log('Cleaning up!');
  };
});
// Logs: "Count: 0"

count.value = 1;
// Logs: "Cleaning up!" then "Count: 1"

dispose();
// Logs: "Cleaning up!"

Common Patterns ​

DOM Manipulation ​

typescript
import { createSignal, createEffect } from '@signalis/core';

const message = createSignal('Hello!');

createEffect(() => {
  const element = document.getElementById('message');
  if (element) {
    element.textContent = message.value;
  }
});

// Update the signal - DOM updates automatically
message.value = 'Welcome to Signalis!';

Logging ​

typescript
import { createSignal, createEffect } from '@signalis/core';

const logLevel = createSignal('info');

createEffect(() => {
  console.log(`Log level changed to: ${logLevel.value}`);
});

// Update log level - effect runs automatically
logLevel.value = 'debug';
// Logs: "Log level changed to: debug"

logLevel.value = 'error';
// Logs: "Log level changed to: error"

API Calls ​

typescript
import { createSignal, createEffect } from '@signalis/core';

const selectedUserId = createSignal(null);

createEffect(() => {
  if (selectedUserId.value) {
    console.log(`Loading user ${selectedUserId.value}...`);
    fetch(`/api/users/${selectedUserId.value}`)
      .then((res) => res.json())
      .then((data) => console.log('User data:', data));
  }
});

// User selects a different user - effect runs automatically
selectedUserId.value = 123;
// Logs: "Loading user 123..." and fetches data

Subscriptions ​

typescript
import { createSignal, createEffect } from '@signalis/core';

const topic = createSignal('news');

createEffect(() => {
  const subscription = messagebus.subscribe(topic.value, handleMessage);

  return () => {
    subscription.unsubscribe();
  };
});

Smart Recomputation ​

Effects use the same smart recomputation as derived values, but they're for side effects, not values:

typescript
import { createSignal, createDerived, createEffect } from '@signalis/core';

const count = createSignal(1);
const isOdd = createDerived(() => count.value % 2 !== 0);

createEffect(() => {
  // This effect only runs when isOdd actually changes
  console.log(`Number is ${isOdd.value ? 'odd' : 'even'}`);
});

count.value = 3; // No log - isOdd is still true
count.value = 4; // Logs: "Number is even" - isOdd changed from true to false
count.value = 6; // No log - isOdd is still false

Disposal and Cleanup ​

Manual Disposal ​

typescript
import { createSignal, createEffect } from '@signalis/core';

const count = createSignal(0);

const dispose = createEffect(() => {
  console.log(`count.value is: ${count.value}`);
});

count.value = 1; // Logs
count.value = 2; // Logs

dispose(); // Stop the effect

count.value = 3; // No log (effect is disposed)

Cleanup Function ​

typescript
import { createSignal, createEffect } from '@signalis/core';

function createTimer() {
  const time = createSignal(0);
  let interval: number;

  const dispose = createEffect(() => {
    console.log(time.value);

    return () => {
      if (interval) {
        clearInterval(interval);
      }
      console.log('Stopped!');
    };
  });

  return {
    start() {
      interval = setInterval(() => {
        time.value++;
      }, 1000);
    },
    stop() {
      dispose();
    },
  };
}

const timer = createTimer();
timer.start(); // Starts logging every second: 1...2...3...
timer.stop(); // Clears interval, logs 'Stopped!'

Reacting to Derived Values ​

Effects work seamlessly with derived values:

typescript
import { createSignal, createDerived, createEffect } from '@signalis/core';

const firstName = createSignal('Jane');
const lastName = createSignal('Doe');

const fullName = createDerived(() => `${firstName.value} ${lastName.value}`);

createEffect(() => {
  console.log(`Full name: ${fullName.value}`);
});
// Logs: "Full name: Jane Doe"

firstName.value = 'Janet';
// Logs: "Full name: Janet Doe"

Conditional Effects ​

Effects can have conditional logic that changes their dependencies:

typescript
import { createSignal, createEffect } from '@signalis/core';

const showDetails = createSignal(false);
const userName = createSignal('Alice');
const userEmail = createSignal('alice@example.com');
const lastLogin = createSignal('2024-01-01');

createEffect(() => {
  if (showDetails.value) {
    // When true: depends on showDetails, userName, and userEmail
    console.log(`User: ${userName.value} (${userEmail.value})`);
  } else {
    // When false: depends on showDetails and lastLogin
    console.log(`Last login: ${lastLogin.value}`);
  }
});

// Initially logs: "Last login: 2024-01-01"
// Effect depends on: showDetails, lastLogin

lastLogin.value = '2024-01-02';
// Logs: "Last login: 2024-01-02"
// Still depends on: showDetails, lastLogin

userName.value = 'Bob'; // No log - userName not in current dependencies

showDetails.value = true;
// Logs: "User: Bob (alice@example.com)"
// Now depends on: showDetails, userName, userEmail

userEmail.value = 'bob@example.com';
// Logs: "User: Bob (bob@example.com)"
// Dependencies remain: showDetails, userName, userEmail

lastLogin.value = '2024-01-03'; // No log - lastLogin not in current dependencies

Effect Cleanup ​

The cleanup function returned from an effect runs when the effect is disposed:

typescript
import { createSignal, createEffect } from '@signalis/core';

const isActive = createSignal(false);
const elapsed = createSignal(0);

const dispose = createEffect(() => {
  if (!isActive.value) return;

  console.log('Starting timer');

  const intervalId = setInterval(() => {
    elapsed.value++;
  }, 1000);

  return () => {
    console.log('Cleaning up timer');
    clearInterval(intervalId);
  };
});

isActive.value = true;
// Logs: "Starting timer"
// Timer starts incrementing elapsed every second

isActive.value = false;
// Logs: "Starting timer" (effect re-runs)
// Does NOT log: "Cleaning up timer"
// Previous interval is still running! Now we have TWO intervals.

dispose();
// Logs: "Cleaning up timer"
// Only now does cleanup run, clearing the most recent interval

Cleanup Use Cases ​

Cleanup is useful for removing event listeners or clearing timers when the effect is disposed:

typescript
import { createSignal, createEffect } from '@signalis/core';

const isTracking = createSignal(false);
const mousePosition = createSignal({ x: 0, y: 0 });

const dispose = createEffect(() => {
  if (!isTracking.value) return;

  const handleMouseMove = (e: MouseEvent) => {
    mousePosition.value = { x: e.clientX, y: e.clientY };
  };

  window.addEventListener('mousemove', handleMouseMove);

  // Cleanup: remove listener when effect is disposed
  return () => {
    window.removeEventListener('mousemove', handleMouseMove);
  };
});

isTracking.value = true; // Start tracking mouse

// Later: stop tracking and clean up
dispose();

Batching Effect Updates ​

Use batch() to prevent effects from running multiple times:

typescript
import { createSignal, createEffect, batch } from '@signalis/core';

const firstName = createSignal('Jane');
const lastName = createSignal('Doe');

createEffect(() => {
  console.log(`Name: ${firstName.value} ${lastName.value}`);
});
// Logs: "Name: Jane Doe"

// Without batching
firstName.value = 'Janet';
// Logs: "Name: Janet Doe"
lastName.value = 'Smith';
// Logs: "Name: Janet Smith"

// With batching
batch(() => {
  firstName.value = 'Jane';
  lastName.value = 'Doe';
});
// Only logs once: "Name: Jane Doe"

Performance Tips ​

Minimize Dependencies ​

Only read signals you actually need to avoid unnecessary re-runs:

typescript
import { createSignal, createEffect } from '@signalis/core';

const userId = createSignal(123);
const searchQuery = createSignal('');

// ❌ Bad: Fetches user profile whenever search query changes too
createEffect(() => {
  const id = userId.value;
  const query = searchQuery.value; // Accidentally creates dependency!

  fetch(`/api/users/${id}`)
    .then((res) => res.json())
    .then((data) => console.log(data));
});

// ✅ Better: Only read the signals you actually use
createEffect(() => {
  fetch(`/api/users/${userId.value}`)
    .then((res) => res.json())
    .then((data) => console.log(data));
});
// Now only re-fetches when userId changes

Gotchas ​

Don't Return Values ​

Effects are for side effects, not values:

typescript
import { createSignal, createEffect, createDerived } from '@signalis/core';

// ❌ Wrong: Returning a non-cleanup value
const doubled = createEffect(() => {
  return count.value * 2; // This is ignored!
});

// ✅ Correct: Use createDerived for values
const doubled = createDerived(() => count.value * 2);

Cleanup Timing ​

Cleanup runs only on disposal, not before effect re-runs:

typescript
import { createSignal, createEffect } from '@signalis/core';

const count = createSignal(0);
let runCount = 0;

const dispose = createEffect(() => {
  count.value; // Track dependency
  const run = ++runCount;
  console.log(`Effect ${run} starting`);

  return () => {
    console.log(`Effect ${run} cleanup`);
  };
});

count.value = 1;
// Logs: "Effect 2 starting"
// Note: Cleanup does NOT run before re-execution

dispose();
// Logs: "Effect 2 cleanup"

This design is intentional to prevent:

  • Infinite loops from cleanup functions mutating reactive state
  • Spurious dependencies from cleanup reads polluting the dependency graph
  • Race conditions during synchronous reactive updates

If you need to clean up resources before each re-run, manage them manually within the effect body:

typescript
import { createSignal, createEffect } from '@signalis/core';

const delay = createSignal(1000);
let intervalId: number | null = null;

const dispose = createEffect(() => {
  const currentDelay = delay.value;

  // Clean up previous interval manually
  if (intervalId !== null) {
    clearInterval(intervalId);
  }

  // Set up new interval
  intervalId = setInterval(() => {
    console.log('Tick with delay:', currentDelay);
  }, currentDelay);

  // This cleanup only runs on disposal
  return () => {
    if (intervalId !== null) {
      clearInterval(intervalId);
    }
  };
});

delay.value = 500;
// Manually clears old interval and creates new one

dispose();
// Cleanup function runs, clearing the final interval

Avoid Infinite Loops ​

Don't update signals that the effect depends on:

typescript
import { createSignal, createEffect } from '@signalis/core';

const count = createSignal(0);

// ❌ Infinite loop!
createEffect(() => {
  console.log(count.value);
  count.value++; // This triggers the effect again!
});

// ✅ Don't update dependencies inside effects
createEffect(() => {
  console.log(count.value);
  // Safe: use a different signal or external state
});

Effects vs Derived ​

FeatureEffectsDerived
PurposeSide effectsComputed values
EvaluationEagerLazy
Return valueCleanup functionComputed value
Side effectsEncouragedAvoid
ReadonlyN/AYes

See Also ​

  • Error Handling - Where an effect's errors go, and setErrorHandler
  • Signals - Writable reactive values
  • Derived - Computed values (use instead of effects for values)
  • Utilities - batch() for grouping updates
  • Resources - For async operations with loading states

Released under the MIT License.