Skip to content

useSignalEffect

The useSignalEffect hook creates an effect that automatically tracks signal dependencies and re-runs when they change, similar to React's useEffect but with automatic signal tracking.

Signature

ts
function useSignalEffect(fn: () => void | (() => void), deps?: DependencyList): void;

Basic Usage

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const Counter = reactor(() => {
  const count = useSignal(0);

  useSignalEffect(() => {
    console.log(`Count changed to: ${count.value}`);
  });

  return (
    <div>
      <p>Count: {count.value}</p>
      <button onClick={() => count.value++}>Increment</button>
    </div>
  );
});

Signal-Only Dependencies

When your effect only depends on signals, no dependency array is needed:

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const App = reactor(() => {
  const count = useSignal(0);
  const name = useSignal('Jane');

  // No deps array - signal dependencies tracked automatically
  useSignalEffect(() => {
    document.title = `${name.value}: ${count.value}`;
  });

  return <div>...</div>;
});

Mixed Dependencies

When depending on both signals AND non-signal values (props, useState, useContext), include non-signal values in the deps array:

typescript
const UserProfile = reactor(({ userId }: { userId: string }) => {
  const refreshCount = useSignal(0);

  useSignalEffect(() => {
    // Effect runs when:
    // 1. Component mounts
    // 2. userId prop changes (via deps array)
    // 3. refreshCount signal changes (via automatic tracking)
    fetchUserData(userId, refreshCount.value);
  }, [userId]); // userId must be in deps!

  return (
    <div>
      <button onClick={() => refreshCount.value++}>Refresh</button>
    </div>
  );
});

Cleanup

Return a cleanup function to run on unmount or before the next effect run:

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const Timer = reactor(() => {
  const count = useSignal(0);

  useSignalEffect(() => {
    const interval = setInterval(() => {
      count.value++;
    }, 1000);

    // Cleanup function
    return () => {
      clearInterval(interval);
    };
  });

  return <div>Count: {count.value}</div>;
});

Comparison with useEffect

Traditional useEffect

tsx
import { useState, useEffect } from 'react';

function Logger() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Jane');

  useEffect(() => {
    console.log(`${name}: ${count}`);
  }, [name, count]); // Must list all dependencies

  return <div>...</div>;
}

With useSignalEffect

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const Logger = reactor(() => {
  const count = useSignal(0);
  const name = useSignal('Jane');

  useSignalEffect(() => {
    console.log(`${name.value}: ${count.value}`);
  }); // No dependency array needed!

  return <div>...</div>;
});

Common Patterns

Document Title

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const App = reactor(() => {
  const count = useSignal(0);

  useSignalEffect(() => {
    document.title = `Count: ${count.value}`;
  });

  return <div>{count.value}</div>;
});

Local Storage Sync

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const Counter = reactor(() => {
  const count = useSignal(() => {
    const saved = localStorage.getItem('count');
    return saved ? parseInt(saved, 10) : 0;
  });

  useSignalEffect(() => {
    localStorage.setItem('count', count.value.toString());
  });

  return (
    <div>
      <p>{count.value}</p>
      <button onClick={() => count.value++}>+</button>
    </div>
  );
});

Event Listeners

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const WindowSize = reactor(() => {
  const width = useSignal(window.innerWidth);
  const height = useSignal(window.innerHeight);

  useSignalEffect(() => {
    const handleResize = () => {
      width.value = window.innerWidth;
      height.value = window.innerHeight;
    };

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  });

  return (
    <div>
      {width.value} x {height.value}
    </div>
  );
});

API Calls

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

interface User {
  name: string;
}

const UserProfile = reactor(({ userId }: { userId: string }) => {
  const user = useSignal<User | null>(null);
  const loading = useSignal(false);

  useSignalEffect(() => {
    loading.value = true;

    fetchUser(userId)
      .then((data) => (user.value = data))
      .finally(() => (loading.value = false));
  }, [userId]); // userId is a prop, must be in deps

  if (loading.value) return <div>Loading...</div>;
  if (!user.value) return <div>No user</div>;

  return <div>{user.value.name}</div>;
});
tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const Search = reactor(() => {
  const query = useSignal('');
  const results = useSignal<Array<string>>([]);

  useSignalEffect(() => {
    const timeoutId = setTimeout(() => {
      if (query.value) {
        searchAPI(query.value).then((data) => (results.value = data));
      } else {
        results.value = [];
      }
    }, 300);

    return () => clearTimeout(timeoutId);
  });

  return (
    <div>
      <input value={query.value} onChange={(e) => (query.value = e.target.value)} />
      <ul>
        {results.value.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
});

Logging

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const DebugLogger = reactor(() => {
  const count = useSignal(0);
  const name = useSignal('Jane');

  useSignalEffect(() => {
    console.log('State changed:', {
      count: count.value,
      name: name.value,
      timestamp: new Date().toISOString(),
    });
  });

  return <div>...</div>;
});

With Props and Context

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const DataFetcher = reactor(({ endpoint }: { endpoint: string }) => {
  const data = useSignal(null);
  const refreshTrigger = useSignal(0);

  useSignalEffect(() => {
    fetch(endpoint)
      .then((res) => res.json())
      .then((json) => (data.value = json));

    // Depends on: endpoint (prop) and refreshTrigger (signal)
  }, [endpoint]); // endpoint must be in deps

  const refresh = () => refreshTrigger.value++;

  return (
    <div>
      <pre>{JSON.stringify(data.value, null, 2)}</pre>
      <button onClick={refresh}>Refresh</button>
    </div>
  );
});

Conditional Effects

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

const ConditionalLogger = reactor(() => {
  const count = useSignal(0);
  const enableLogging = useSignal(false);

  useSignalEffect(() => {
    if (enableLogging.value) {
      console.log('Count:', count.value);
    }
  });

  return (
    <div>
      <p>Count: {count.value}</p>
      <label>
        <input
          type="checkbox"
          checked={enableLogging.value}
          onChange={(e) => (enableLogging.value = e.target.checked)}
        />
        Enable Logging
      </label>
      <button onClick={() => count.value++}>Increment</button>
    </div>
  );
});

Async Effects

tsx
import { useSignal, useSignalEffect, reactor } from '@signalis/react';

interface User {
  name: string;
}

const AsyncDataLoader = reactor(() => {
  const userId = useSignal<number | null>(null);
  const user = useSignal<User | null>(null);

  useSignalEffect(() => {
    if (!userId.value) return;

    let cancelled = false;

    fetchUser(userId.value).then((data) => {
      if (!cancelled) {
        user.value = data;
      }
    });

    // Prevent stale updates
    return () => {
      cancelled = true;
    };
  });

  return <div>...</div>;
});

Dependency Array Rules

Same rules as useDerived:

Signal-Only: No deps array

tsx
// ✅ Good: Only signals
useSignalEffect(() => {
  console.log(signal1.value, signal2.value);
});

Mixed: Non-signals in deps

tsx
// ✅ Good: prop in deps
useSignalEffect(() => {
  api.call(userId, signal.value);
}, [userId]);

// ✅ Good: state and context in deps
useSignalEffect(() => {
  doSomething(stateValue, contextValue, signal.value);
}, [stateValue, contextValue]);

Common Mistake: Missing deps

tsx
// ❌ Bad: userId not in deps - stale closure!
useSignalEffect(() => {
  fetchUser(userId, count.value);
});

// ✅ Correct
useSignalEffect(() => {
  fetchUser(userId, count.value);
}, [userId]);

Effect Timing

useSignalEffect runs after React has committed to the DOM (like useEffect), not during render.

Gotchas

Don't Return Non-Functions

tsx
// ❌ Wrong: returning a value
useSignalEffect(() => {
  return count.value * 2; // This is ignored!
});

// ✅ Correct: return cleanup function or nothing
useSignalEffect(() => {
  console.log(count.value);
});

useSignalEffect(() => {
  const id = setInterval(() => {}, 1000);
  return () => clearInterval(id); // Cleanup
});

Don't Forget Non-Signal Deps

tsx
// ❌ Wrong: prop not in deps
const Component = reactor(({ apiKey }) => {
  useSignalEffect(() => {
    api.setKey(apiKey); // apiKey changes won't be seen!
    fetchData(signal.value);
  });
});

// ✅ Correct
const Component = reactor(({ apiKey }) => {
  useSignalEffect(() => {
    api.setKey(apiKey);
    fetchData(signal.value);
  }, [apiKey]);
});

Avoid Infinite Loops

tsx
// ❌ Infinite loop!
useSignalEffect(() => {
  count.value++; // Updates count, which triggers effect again!
});

// ✅ Correct: use a different signal or external state
useSignalEffect(() => {
  otherSignal.value = count.value + 1; // OK if this doesn't loop back
});

See Also

Released under the MIT License.