---
title: Signals
description: Create reactive signals and computed values, share state across islands, batch writes, and read values without creating dependencies.
---

Reactive signals are the primitive that powers state in ilha. In addition to `.state()` (local to an island), ilha exports several helpers for cross-island sharing, derived values, side effects, performance, and control:

| Helper       | Purpose                                                      |
| ------------ | ------------------------------------------------------------ |
| `signal()`   | Create a free-standing signal for one-off shared state       |
| `computed()` | Create a free-standing read-only value derived from signals  |
| `context()`  | Create a named global signal accessible from anywhere by key |
| `effect()`   | Run a reactive side effect outside any island                |
| `batch()`    | Group multiple writes into a single propagation pass         |
| `untrack()`  | Read a signal without subscribing the surrounding scope      |

---

## `signal(initial)`

Creates a free-standing reactive signal that lives outside any island. Useful for sharing state across multiple islands without prop drilling, or for binding form inputs to module-level state.

### Basic usage

```ts twoslash
import { signal } from "ilha";

const count = signal(0);

count(); // → 0  (read)
count(5); // → sets to 5 (write)
count((previous) => previous + 1); // → updates from the latest value
```

The updater form also works with `context()` and nested `.select()` accessors. If a signal stores a function, return the replacement function from an updater: `callback(() => nextCallback)`.

Reading the signal inside any reactive scope — `.render()`, `.derived()`, `.effect()` — automatically subscribes that scope, so when the signal changes, dependents re-run as if it were local state.

### Sharing state between islands

Because `signal()` returns a plain accessor, you can import it into any island. When one island writes to it, all others that read it re-render automatically:

```tsx twoslash
import { ilha, signal } from "ilha";

const cartCount = signal(0);

const CartButton = ilha
  .action("addToCart", () => cartCount((count) => count + 1)) // [!code highlight]
  .render(({ action }) => (
    <button onclick={action.addToCart}>Add to cart</button>
  ));

const CartBadge = ilha(() => <span>{cartCount()}</span>); // [!code highlight]
```

Both islands share the same `cartCount` signal. Clicking the button in `CartButton` updates the badge in `CartBadge` without any wiring between them.

### Using signals in `bind:` bindings

Pass a signal directly into a `bind:` attribute to sync a form element with module-level state:

```tsx twoslash
import { ilha, signal } from "ilha";

const query = signal("");

const SearchInput = ilha(
  () => <input type="search" bind:value={query} />, // [!code highlight]
);

const SearchResults = ilha(() => <p>Results for: {query()}</p>);
```

When the user types, `query` updates and `SearchResults` re-renders automatically — no wiring between islands needed.

---

## `computed(fn)`

Creates a free-standing, read-only value derived from other signals. `fn` is lazy and cached — it re-runs only when a signal it read has changed and the computed is read again.

```ts twoslash
import { signal, computed } from "ilha";

const items = signal([1, 2, 3]);
const total = computed(() =>
  items().reduce((a, b) => a + b, 0),
);

total(); // → 6  (runs fn once, caches the result)
total(); // → 6  (cached, fn does not re-run)
items([1, 2, 3, 4]);
total(); // → 10 (a dependency changed, fn re-runs)
```

Reading a computed inside any reactive scope — `.render()`, `.derived()`, `.effect()`, or top-level [`effect()`](#effectfn) — subscribes that scope, so dependents re-run when the computed's resolved value changes:

```tsx twoslash
import { ilha, signal, computed } from "ilha";

const base = signal(1);
const doubled = computed(() => base() * 2);

const Island = ilha(() => <p>{doubled()}</p>);
```

### Read-only

Writing to a computed is ignored and logs a dev warning — use [`signal()`](#signalinitial) if you need a writable value:

```ts twoslash
import { computed } from "ilha";

const c = computed(() => 1);
(c as (v: number) => void)(99); // ignored, dev warning logged
c(); // → 1
```

---

## `context(key, initial)`

Creates a **named global signal** — a reactive signal shared across all islands. Identical keys always return the same signal instance, which makes it useful for app-wide singletons (theme, locale, current user) where you want registry semantics.

```ts twoslash
import { context } from "ilha";

const theme = context("app.theme", "light");

theme(); // → "light"
theme("dark"); // → sets to "dark"
```

### `signal()` vs `context()`

Both return the same accessor shape and can be used with `bind:` template syntax. Reach for `signal()` when you hold the reference yourself and import it where needed. Reach for `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key — for example, when the consumer lives in a different package or module from where the signal is defined.

### Sharing state between islands

Any island that calls `context()` with the same key gets the same signal. When one island writes to it, all others that read it re-render automatically:

```tsx twoslash
import { ilha, context } from "ilha";

const cartCount = context("cart.count", 0);

const CartButton = ilha
  .action("addToCart", () => cartCount((count) => count + 1)) // [!code highlight]
  .render(({ action }) => (
    <button onclick={action.addToCart}>Add to cart</button>
  ));

const CartBadge = ilha(() => <span>{cartCount()}</span>); // [!code highlight]
```

### Using context in `bind:` bindings

Pass a context signal directly into a `bind:` attribute to sync a form element across islands:

```tsx twoslash
import { ilha, context } from "ilha";

const query = context("search.query", "");

const SearchInput = ilha(
  () => <input type="search" bind:value={query} />, // [!code highlight]
);

const SearchResults = ilha(() => <p>Results for: {query()}</p>);
```

### Initializing with a type

The second argument sets the initial value and infers the signal type. The type is fixed at first call — subsequent calls with the same key return the existing signal regardless of what initial value is passed:

```tsx twoslash
import { context } from "ilha";

const count = context("ui.count", 0); // creates signal<number>
const same = context("ui.count", 999); // returns same signal, ignores 999
```

This means context initialization is effectively first-write-wins. Define context signals in a shared module to ensure consistent initialization across your app:

```ts twoslash
// contexts.ts
import { context } from "ilha";

export const theme = context("app.theme", "light");
export const userId = context(
  "app.userId",
  null as string | null,
);
export const sidebar = context("ui.sidebar", true);
```

### Reading context inside effects and derived

Context signals are reactive — reading them inside [`.effect()`](/guide/island/effect) or [`.derived()`](/guide/island/derived) creates a dependency just like reading local state:

```tsx twoslash
import { ilha, context } from "ilha";

const theme = context("app.theme", "light");

const Island = ilha
  .effect(() => {
    document.documentElement.dataset["theme"] = theme();
  })
  .render(() => <div>content</div>);
```

Whenever `theme` is updated anywhere in the app, this effect re-runs.

### SSR behavior

`context()` is safe to call during SSR. The registry is module-level, so signals persist for the lifetime of the process. In a server environment where requests share the same module instance, be careful not to store user-specific state in context signals — use [`.input()`](/guide/island/input) and [`.state()`](/guide/island/state) for per-request data instead.

### Releasing entries — `context.delete()` and `context.clear()`

The registry is otherwise append-only. Long-lived SPAs that mint dynamic keys, HMR cycles, and tests all need a way to release entries:

```ts twoslash
import { context } from "ilha";

context.delete("cart.count"); // → true if it existed, false otherwise
context.clear(); // remove every context signal
```

Deleting a key does not affect accessors already handed out — they keep working against their original signal. It only means the next `context(key, initial)` call creates a fresh signal instead of returning the old one.

```ts twoslash
import { context } from "ilha";

const first = context("ui.count", 0);
first(5);

context.delete("ui.count");
const second = context("ui.count", 0); // fresh signal, starts at 0

first(); // → 5, unaffected by the delete
second(); // → 0
```

---

## `effect(fn)`

Runs a free-standing reactive effect outside any island. `fn` runs once immediately and re-runs whenever a signal it read changes.

```ts twoslash
import { signal, effect } from "ilha";

const cart = signal({ count: 0 });

const stop = effect(() => {
  document.title = `${cart().count} items`;
});
```

Import `effect` from `"ilha"` as a named export — `import { effect } from "ilha"`. It is deliberately not exposed as `ilha.effect()`: the [island builder method](/guide/island/effect) of that name registers a per-island effect. The top-level `effect()` documented here is a standalone helper for code that runs outside any island.

### Cleanup and stopping

`fn` may return a cleanup function, invoked before each re-run and once more when the effect is stopped:

```ts twoslash
import { signal, effect } from "ilha";

const delay = signal(1000);

const stop = effect(() => {
  const id = setInterval(() => console.log("tick"), delay());
  return () => clearInterval(id); // [!code highlight]
});

stop(); // disposes the effect and runs the final cleanup
```

### Implicit batching

Multiple synchronous signal writes inside the effect body produce a single propagation pass, the same as inside `.on()` handlers or `.effect()`:

```ts twoslash
import { signal, effect } from "ilha";

const a = signal(0);
const b = signal(0);

effect(() => {
  a(a() + 1);
  b(b() + 1); // both writes flush together
});
```

---

## `batch(fn)`

Runs `fn` as an atomic batch — multiple signal writes inside the callback produce a single propagation pass, so dependents (effects, deriveds, island re-renders) see the final state and run once instead of once per write. Returns whatever `fn` returns.

### Before and after

Without batch, each write triggers its own propagation pass:

```ts twoslash
import { signal } from "ilha";

const a = signal(0);
const b = signal(0);

a(1); // → effects re-run
b(2); // → effects re-run again
```

With batch, both writes flush together:

```ts twoslash
import { signal, batch } from "ilha";

const a = signal(0);
const b = signal(0);

batch(() => {
  a(10);
  b(20);
}); // → effects re-run once
```

### Implicit batching

`.on()` handlers and `.effect()` runs are batched implicitly, so you only need `batch()` when triggering multiple writes from outside an island — for example from a top-level event listener, a `setTimeout` callback, or a WebSocket message handler.

### Nesting

Nested `batch()` calls are safe and only flush when the outermost batch ends:

```ts twoslash
import { signal, batch } from "ilha";

const count = signal(0);

batch(() => {
  batch(() => {
    count(1);
  }); // still inside outer batch — no flush yet
  count(2);
}); // outermost batch ends — single flush
```

---

## `untrack(fn)`

Runs `fn` with reactive tracking suspended. Reading signals inside `fn` returns their current value without subscribing the surrounding scope. Use this in effects or deriveds when you want to peek at state without causing a re-run on its changes.

### React to A, peek at B

The canonical pattern: an effect should re-run when `tracked` changes, but read `peeked` only as a one-off value:

```tsx twoslash
import { ilha, signal, untrack } from "ilha";

const tracked = signal(0);
const peeked = signal("hello");

const Island = ilha
  .effect(() => {
    // Re-runs when `tracked` changes, but NOT when `peeked` changes.
    console.log(
      tracked(),
      untrack(() => peeked()),
    );
  })
  .render(() => <p>x</p>);
```

`untrack()` returns whatever `fn` returns, so it also works for peeking at derived values or any other reactive read:

```ts twoslash
import { signal, untrack } from "ilha";

const s = signal(42);
const value = untrack(() => s()); // → 42, no subscription created
```

---

## Notes

- `signal()` vs `context()` — both return the same accessor shape and can be used with `bind:` template syntax. Use `signal()` for one-off shared state where you hold the reference; use `context()` when you want a name-keyed registry.
- Keys are global strings. Use namespaced keys like `"app.theme"` or `"cart.count"` to avoid accidental collisions across different parts of your app.
- Use `context.delete(key)` or `context.clear()` to release entries — useful in HMR, tests, or long-lived SPAs that mint dynamic keys. See [Releasing entries](#releasing-entries--contextdelete-and-contextclear).
- Context signals are not included in [`.hydratable()`](/guide/island/hydratable) snapshots. If you need server-rendered context values on the client, pass them as island props via [`.input()`](/guide/island/input) and initialize the context signal inside [`.onMount()`](/guide/island/onmount).
- `computed()` values are read-only; they cannot be used as `bind:` targets. Use `signal()` for writable bindings.
- Top-level `effect()` (imported from `"ilha"`) is distinct from the `.effect()` builder method — see [`effect(fn)`](#effectfn) above.
