|
| 1 | +--- |
| 2 | +title: Signals |
| 3 | +description: Create free-standing reactive signals, share state across islands, batch writes, and peek at values without creating dependencies. |
| 4 | +--- |
| 5 | + |
| 6 | +# Signals |
| 7 | + |
| 8 | +Reactive signals are the primitive that powers state in ilha. In addition to `.state()` (local to an island), ilha exports four signal helpers for cross-island sharing, performance, and control: |
| 9 | + |
| 10 | +| Helper | Purpose | |
| 11 | +| ----------- | ------------------------------------------------------------ | |
| 12 | +| `signal()` | Create a free-standing signal for one-off shared state | |
| 13 | +| `context()` | Create a named global signal accessible from anywhere by key | |
| 14 | +| `batch()` | Group multiple writes into a single propagation pass | |
| 15 | +| `untrack()` | Read a signal without subscribing the surrounding scope | |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## `signal(initial)` |
| 20 | + |
| 21 | +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. |
| 22 | + |
| 23 | +### Basic usage |
| 24 | + |
| 25 | +```ts twoslash |
| 26 | +import { signal } from "ilha"; |
| 27 | + |
| 28 | +const count = signal(0); |
| 29 | + |
| 30 | +count(); // → 0 (read) |
| 31 | +count(5); // → sets to 5 (write) |
| 32 | +``` |
| 33 | + |
| 34 | +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. |
| 35 | + |
| 36 | +### Sharing state between islands |
| 37 | + |
| 38 | +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: |
| 39 | + |
| 40 | +```ts twoslash |
| 41 | +import ilha, { html, signal } from "ilha"; |
| 42 | + |
| 43 | +const cartCount = signal(0); |
| 44 | + |
| 45 | +const CartButton = ilha |
| 46 | + .on("button@click", () => cartCount(cartCount() + 1)) // [!code highlight] |
| 47 | + .render(() => html` <button>Add to cart</button> `); |
| 48 | + |
| 49 | +const CartBadge = ilha |
| 50 | + // [!code highlight] |
| 51 | + .render(() => html`<span>${cartCount()}</span>`); |
| 52 | +``` |
| 53 | + |
| 54 | +Both islands share the same `cartCount` signal. Clicking the button in `CartButton` updates the badge in `CartBadge` without any wiring between them. |
| 55 | + |
| 56 | +### Using signals in [`.bind()`](/guide/island/bind) |
| 57 | + |
| 58 | +Pass a signal directly to `.bind()` to sync a form element across islands: |
| 59 | + |
| 60 | +```ts twoslash |
| 61 | +import ilha, { html, signal } from "ilha"; |
| 62 | + |
| 63 | +const query = signal(""); |
| 64 | + |
| 65 | +const SearchInput = ilha |
| 66 | + // [!code highlight] |
| 67 | + .bind("input", query) |
| 68 | + .render(() => html`<input type="search" />`); |
| 69 | + |
| 70 | +const SearchResults = ilha.render(() => html`<p>Results for: ${query()}</p>`); |
| 71 | +``` |
| 72 | + |
| 73 | +--- |
| 74 | + |
| 75 | +## `context(key, initial)` |
| 76 | + |
| 77 | +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. |
| 78 | + |
| 79 | +```ts twoslash |
| 80 | +import { context } from "ilha"; |
| 81 | + |
| 82 | +const theme = context("app.theme", "light"); |
| 83 | + |
| 84 | +theme(); // → "light" |
| 85 | +theme("dark"); // → sets to "dark" |
| 86 | +``` |
| 87 | + |
| 88 | +### `signal()` vs `context()` |
| 89 | + |
| 90 | +Both return the same accessor shape and can be passed to `.bind()`. 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. |
| 91 | + |
| 92 | +### Sharing state between islands |
| 93 | + |
| 94 | +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: |
| 95 | + |
| 96 | +```ts twoslash |
| 97 | +import ilha, { html, context } from "ilha"; |
| 98 | + |
| 99 | +const cartCount = context("cart.count", 0); |
| 100 | + |
| 101 | +const CartButton = ilha |
| 102 | + .on("button@click", () => cartCount(cartCount() + 1)) // [!code highlight] |
| 103 | + .render(() => html` <button>Add to cart</button> `); |
| 104 | + |
| 105 | +const CartBadge = ilha |
| 106 | + // [!code highlight] |
| 107 | + .render(() => html`<span>${cartCount()}</span>`); |
| 108 | +``` |
| 109 | + |
| 110 | +### Using context in [`.bind()`](/guide/island/bind) |
| 111 | + |
| 112 | +Pass a context signal directly to `.bind()` to sync a form element across islands: |
| 113 | + |
| 114 | +```ts twoslash |
| 115 | +import ilha, { html, context } from "ilha"; |
| 116 | + |
| 117 | +const query = context("search.query", ""); |
| 118 | + |
| 119 | +const SearchInput = ilha |
| 120 | + // [!code highlight] |
| 121 | + .bind("input", query) |
| 122 | + .render(() => html`<input type="search" />`); |
| 123 | + |
| 124 | +const SearchResults = ilha.render(() => html`<p>Results for: ${query()}</p>`); |
| 125 | +``` |
| 126 | + |
| 127 | +### Initializing with a type |
| 128 | + |
| 129 | +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: |
| 130 | + |
| 131 | +```ts twoslash |
| 132 | +import { context } from "ilha"; |
| 133 | + |
| 134 | +const count = context("ui.count", 0); // creates signal<number> |
| 135 | +const same = context("ui.count", 999); // returns same signal, ignores 999 |
| 136 | +``` |
| 137 | + |
| 138 | +This means context initialization is effectively first-write-wins. Define context signals in a shared module to ensure consistent initialization across your app: |
| 139 | + |
| 140 | +```ts |
| 141 | +// contexts.ts |
| 142 | +import { context } from "ilha"; |
| 143 | + |
| 144 | +export const theme = context("app.theme", "light"); |
| 145 | +export const userId = context("app.userId", null as string | null); |
| 146 | +export const sidebar = context("ui.sidebar", true); |
| 147 | +``` |
| 148 | + |
| 149 | +### Reading context inside effects and derived |
| 150 | + |
| 151 | +Context signals are reactive — reading them inside [`.effect()`](/guide/island/effect) or [`.derived()`](/guide/island/derived) creates a dependency just like reading local state: |
| 152 | + |
| 153 | +```ts twoslash |
| 154 | +import ilha, { context } from "ilha"; |
| 155 | + |
| 156 | +const theme = context("app.theme", "light"); |
| 157 | + |
| 158 | +const Island = ilha |
| 159 | + .effect(() => { |
| 160 | + document.documentElement.dataset["theme"] = theme(); |
| 161 | + }) |
| 162 | + .render(() => `<div>content</div>`); |
| 163 | +``` |
| 164 | + |
| 165 | +Whenever `theme` is updated anywhere in the app, this effect re-runs. |
| 166 | + |
| 167 | +### SSR behavior |
| 168 | + |
| 169 | +`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. |
| 170 | + |
| 171 | +--- |
| 172 | + |
| 173 | +## `batch(fn)` |
| 174 | + |
| 175 | +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. |
| 176 | + |
| 177 | +### Before and after |
| 178 | + |
| 179 | +Without batch, each write triggers its own propagation pass: |
| 180 | + |
| 181 | +```ts twoslash |
| 182 | +import { signal } from "ilha"; |
| 183 | + |
| 184 | +const a = signal(0); |
| 185 | +const b = signal(0); |
| 186 | + |
| 187 | +a(1); // → effects re-run |
| 188 | +b(2); // → effects re-run again |
| 189 | +``` |
| 190 | + |
| 191 | +With batch, both writes flush together: |
| 192 | + |
| 193 | +```ts twoslash |
| 194 | +import { signal, batch } from "ilha"; |
| 195 | + |
| 196 | +const a = signal(0); |
| 197 | +const b = signal(0); |
| 198 | + |
| 199 | +batch(() => { |
| 200 | + a(10); |
| 201 | + b(20); |
| 202 | +}); // → effects re-run once |
| 203 | +``` |
| 204 | + |
| 205 | +### Implicit batching |
| 206 | + |
| 207 | +`.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. |
| 208 | + |
| 209 | +### Nesting |
| 210 | + |
| 211 | +Nested `batch()` calls are safe and only flush when the outermost batch ends: |
| 212 | + |
| 213 | +```ts twoslash |
| 214 | +import { signal, batch } from "ilha"; |
| 215 | + |
| 216 | +const count = signal(0); |
| 217 | + |
| 218 | +batch(() => { |
| 219 | + batch(() => { |
| 220 | + count(1); |
| 221 | + }); // still inside outer batch — no flush yet |
| 222 | + count(2); |
| 223 | +}); // outermost batch ends — single flush |
| 224 | +``` |
| 225 | + |
| 226 | +--- |
| 227 | + |
| 228 | +## `untrack(fn)` |
| 229 | + |
| 230 | +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. |
| 231 | + |
| 232 | +### React to A, peek at B |
| 233 | + |
| 234 | +The canonical pattern: an effect should re-run when `tracked` changes, but read `peeked` only as a one-off value: |
| 235 | + |
| 236 | +```ts twoslash |
| 237 | +import ilha, { signal, untrack } from "ilha"; |
| 238 | + |
| 239 | +const tracked = signal(0); |
| 240 | +const peeked = signal("hello"); |
| 241 | + |
| 242 | +const Island = ilha |
| 243 | + .effect(() => { |
| 244 | + // Re-runs when `tracked` changes, but NOT when `peeked` changes. |
| 245 | + console.log( |
| 246 | + tracked(), |
| 247 | + untrack(() => peeked()), |
| 248 | + ); |
| 249 | + }) |
| 250 | + .render(() => `<p>x</p>`); |
| 251 | +``` |
| 252 | + |
| 253 | +`untrack()` returns whatever `fn` returns, so it also works for peeking at derived values or any other reactive read: |
| 254 | + |
| 255 | +```ts twoslash |
| 256 | +import { signal, untrack } from "ilha"; |
| 257 | + |
| 258 | +const s = signal(42); |
| 259 | +const value = untrack(() => s()); // → 42, no subscription created |
| 260 | +``` |
| 261 | + |
| 262 | +--- |
| 263 | + |
| 264 | +## Notes |
| 265 | + |
| 266 | +- `signal()` vs `context()` — both return the same accessor shape and can be passed to `.bind()`. Use `signal()` for one-off shared state where you hold the reference; use `context()` when you want a name-keyed registry. |
| 267 | +- Keys are global strings. Use namespaced keys like `"app.theme"` or `"cart.count"` to avoid accidental collisions across different parts of your app. |
| 268 | +- There is no way to delete or reset a context signal once created short of reloading the module. |
| 269 | +- 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). |
0 commit comments