@blueshed/railroad 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,86 +1,91 @@
1
1
  # Railroad — Skill for Claude
2
2
 
3
- You are helping a developer build a UI with `@blueshed/railroad`, a micro reactive framework for Bun.
3
+ Micro reactive UI framework for Bun. ~900 lines, zero dependencies, real DOM.
4
4
 
5
- ## What Railroad Is
5
+ **Source files are the canonical reference.** Each has a JSDoc header with full API docs. Read the source when you need detail:
6
+ - `signals.ts` — signal, computed, effect, batch, dispose scopes
7
+ - `jsx.ts` — createElement, Fragment, text, when, list, SVG adoption
8
+ - `routes.ts` — routes, route, navigate, matchRoute
9
+ - `shared.ts` — key, provide, inject, tryInject
10
+ - `logger.ts` — createLogger, setLogLevel, loggedRequest
6
11
 
7
- - ~400 lines. Zero dependencies. Real DOM — no virtual DOM, no compiler, no build step.
8
- - Designed for Bun. TypeScript source files are the distribution (no transpile step).
9
- - Four concerns: **signals** (state), **JSX** (DOM), **routes** (navigation), **shared** (dependency injection).
12
+ ## Setup
10
13
 
11
- ## When to Use This Skill
12
-
13
- Use railroad when the developer has it installed (`@blueshed/railroad` in package.json) or asks to build a UI with signals and JSX for Bun. Do NOT mix railroad with React, Preact, Solid, or any other framework — they are incompatible.
14
+ ```json
15
+ // tsconfig.json — automatic runtime (recommended)
16
+ { "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
17
+ ```
14
18
 
15
- ## Critical Setup
19
+ ```ts
20
+ // server.ts
21
+ import home from "./index.html";
22
+ Bun.serve({ routes: { "/": home } });
23
+ ```
16
24
 
17
- ### Automatic runtime (recommended)
25
+ ## API Quick Reference
18
26
 
19
- No JSX imports needed — the compiler inserts them automatically.
27
+ ### Signals (`signals.ts`)
20
28
 
21
- ```json
22
- {
23
- "compilerOptions": {
24
- "jsx": "react-jsx",
25
- "jsxImportSource": "@blueshed/railroad"
26
- }
27
- }
29
+ ```ts
30
+ signal<T>(value): Signal<T> // mutable reactive value
31
+ computed<T>(fn): Signal<T> // derived, auto-tracked, auto-disposed in scope
32
+ effect(fn): Dispose // side-effect, returns dispose function
33
+ batch(fn): void // group writes, flush once
28
34
  ```
29
35
 
30
- ### Classic runtime
36
+ Signal methods: `.get()` `.set(v)` `.update(fn)` `.mutate(fn)` `.patch(partial)` `.peek()`
31
37
 
32
- If the project uses explicit imports, every `.tsx` file that uses JSX **must** import `createElement` (and `Fragment` if using `<>...</>`):
38
+ Dispose scopes: `pushDisposeScope()` `popDisposeScope(): Dispose` `trackDispose(fn)`
33
39
 
34
- ```json
35
- {
36
- "compilerOptions": {
37
- "jsx": "react",
38
- "jsxFactory": "createElement",
39
- "jsxFragmentFactory": "Fragment"
40
- }
41
- }
40
+ ### JSX (`jsx.ts`)
41
+
42
+ Components are functions called **once**. Reactivity comes from signals, not re-rendering.
43
+
44
+ ```ts
45
+ createElement(tag, props, ...children): Node
46
+ Fragment(props): DocumentFragment
47
+ text(fn): Node // reactive computed text node
48
+ when(condition, truthy, falsy?): Node // conditional swap on truthiness transition
49
+ list(items, keyFn, render): Node // keyed list, render gets Signal<T>, Signal<number>
50
+ list(items, render): Node // index-based list, render gets raw T
42
51
  ```
43
52
 
44
- Check the project's `tsconfig.json` to see which mode is in use. Prefer the automatic runtime for new projects.
53
+ Props: `class`/`className`, `style` (object or Signal), `value`/`checked`/`disabled`/`selected`/`src`/`srcdoc` (as DOM properties), `innerHTML`, `ref(el)`, `on*` events. All support Signal values for reactivity.
45
54
 
46
- A minimal Bun server to serve the app:
55
+ SVG: `<svg>` children are auto-adopted into SVG namespace.
56
+
57
+ ### Routes (`routes.ts`)
47
58
 
48
59
  ```ts
49
- import home from "./index.html";
50
- Bun.serve({ routes: { "/": home } });
60
+ routes(target, table): Dispose // hash router, auto-disposes on swap
61
+ route<T>(pattern): Signal<T | null> // reactive route match
62
+ navigate(path): void // set location.hash
63
+ matchRoute(pattern, path): params | null
51
64
  ```
52
65
 
53
- ## How to Import
66
+ Handlers receive `(params, params$)` — plain object + Signal. Destructure the first, watch the second for same-pattern param changes (e.g. `/users/1` → `/users/2`).
67
+
68
+ ### Shared (`shared.ts`)
54
69
 
55
70
  ```ts
56
- // Everything from one place:
57
- import { createElement, Fragment, signal, computed, effect, batch, text, when, list, routes, navigate, key, provide, inject } from "@blueshed/railroad";
58
-
59
- // Or by concern:
60
- import { signal, computed, effect, batch } from "@blueshed/railroad/signals";
61
- import { createElement, Fragment, text, when, list } from "@blueshed/railroad/jsx";
62
- import { routes, navigate, route, matchRoute } from "@blueshed/railroad/routes";
63
- import { key, provide, inject } from "@blueshed/railroad/shared";
71
+ key<T>(name): Key<T> // typed symbol key
72
+ provide<T>(key, value): void // register value
73
+ inject<T>(key): T // retrieve value (throws if missing)
74
+ tryInject<T>(key): T | undefined // retrieve value (returns undefined if missing)
64
75
  ```
65
76
 
66
- ## Reference Docs
67
-
68
- Detailed usage for each concern is in the sibling files. Read them before generating railroad code:
77
+ ### Logger (`logger.ts`)
69
78
 
70
- - `signals.md` — reactive state: signal, computed, effect, batch, dispose, mutate, patch
71
- - `jsx.md` — DOM creation: createElement, Fragment, text, when, list, props, events, refs
72
- - `routes.md` hash-based client router: routes, navigate, route, matchRoute
73
- - `shared.md` typed dependency injection: key, provide, inject
79
+ ```ts
80
+ createLogger(tag): { info, warn, error, debug }
81
+ setLogLevel(level): void // "error" | "warn" | "info" | "debug"
82
+ loggedRequest(tag, handler): Handler // wrap route handler with access logging
83
+ ```
74
84
 
75
- ## Anti-Patterns — Do NOT Do These
85
+ ## Anti-Patterns
76
86
 
77
- 1. **No React patterns.** There is no `useState`, `useEffect`, `useRef`, `useCallback`, `useMemo`, or any hooks. There are no lifecycle methods. There is no `React.memo`. Do not import from `react`.
78
- 2. **No virtual DOM.** `createElement` produces real DOM nodes. There is no reconciler, no diffing (except in `list()`), no re-rendering of component trees.
79
- 3. **Components run once.** A component function is called once and returns a DOM node. Reactivity comes from signals, not from re-calling the component.
80
- 4. **No JSX without setup.** In classic mode, every `.tsx` file needs `import { createElement } from "@blueshed/railroad"`. In automatic mode (`react-jsx` + `jsxImportSource`), no import is needed — the compiler handles it. Check tsconfig to know which mode.
81
- 5. **Do not call `.get()` in JSX children.** Pass the signal directly: `<p>{count}</p>`, not `<p>{count.get()}</p>`. The latter creates a static text node that never updates.
82
- 6. **Do not use `text()` for attributes.** `text()` creates a DOM text node — use `computed()` for reactive attribute values: `class={computed(() => active.get() ? "on" : "off")}`.
83
- 7. **Do not create signals inside components** unless you want fresh state on every mount. Module-level signals are shared state; component-level signals are local/ephemeral.
84
- 8. **Do not forget `batch()`** when setting multiple signals that feed the same effect — without it, the effect runs once per set.
85
- 9. **Do not rebuild JSX in effects without dispose scopes.** Any effect that does `container.innerHTML = ""; container.appendChild(<JSX/>)` **must** use the full dispose scope pattern — see `jsx.md` Dispose Scopes section. Both the re-run cleanup (`if (childDispose) childDispose()` at top) AND the effect cleanup return (`return () => { ... }`) are required. Without the return, child effects leak when the parent is torn down.
86
- 10. **Do not use `transition-all` in CSS** for elements near layout boundaries (cards, panels). Use specific properties like `transition-colors` or `transition-[width,border-color]` to avoid animating layout properties unintentionally.
87
+ 1. **No React.** No useState, useEffect, hooks, lifecycle methods, memo, or react imports.
88
+ 2. **No `.get()` in JSX children.** `<p>{count}</p>` is reactive. `<p>{count.get()}</p>` is static.
89
+ 3. **No `text()` for attributes.** `text()` creates a DOM node. Use `computed()` for reactive attributes.
90
+ 4. **No JSX in effects without dispose scopes.** Any effect that rebuilds DOM must use `pushDisposeScope`/`popDisposeScope` and return a cleanup function. See `jsx.ts` source for the pattern.
91
+ 5. **No `transition-all` in CSS** near layout boundaries. Use specific properties.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Signals, JSX, and routes — a micro UI framework for Bun.
4
4
 
5
- ~400 lines. Zero dependencies. Real DOM. No virtual DOM, no compiler, no build step.
5
+ ~900 lines. Zero dependencies. Real DOM. No virtual DOM, no compiler, no build step.
6
6
 
7
7
  ## Install
8
8
 
@@ -178,14 +178,20 @@ function Greeting() {
178
178
 
179
179
  ### Routes
180
180
 
181
- Hash-based client router with automatic dispose scoping.
181
+ Hash-based client router with automatic dispose scoping. Handlers receive `(params, params$)` — destructure the first for convenience, watch the second for reactive param changes.
182
182
 
183
183
  ```tsx
184
- import { routes, navigate } from "@blueshed/railroad";
184
+ import { routes, navigate, effect, text } from "@blueshed/railroad";
185
185
 
186
186
  const dispose = routes(app, {
187
187
  "/": () => <Home />,
188
- "/users/:id": ({ id }) => <User id={id} />,
188
+ // Simple destructure params as before:
189
+ "/about": () => <About />,
190
+ // Reactive — watch params$ for same-pattern navigation (/users/1 → /users/2):
191
+ "/users/:id": ({ id }, params$) => {
192
+ effect(() => fetchUser(params$.get().id));
193
+ return <h1>{text(() => `User ${params$.get().id}`)}</h1>;
194
+ },
189
195
  "*": () => <NotFound />,
190
196
  });
191
197
 
@@ -204,6 +210,26 @@ provide(STORE, createStore());
204
210
 
205
211
  // anywhere:
206
212
  const store = inject(STORE);
213
+
214
+ // Non-throwing variant:
215
+ const maybeStore = tryInject(STORE); // T | undefined
216
+ ```
217
+
218
+ ### Logger
219
+
220
+ Colored, timestamped, level-gated console output.
221
+
222
+ ```ts
223
+ import { createLogger, setLogLevel, loggedRequest } from "@blueshed/railroad";
224
+
225
+ const log = createLogger("[server]");
226
+ log.info("listening on :3000");
227
+ log.debug("tick"); // only shown when level is "debug"
228
+
229
+ setLogLevel("debug"); // show everything
230
+
231
+ // Wrap a route handler with access logging:
232
+ const handler = loggedRequest("[api]", myHandler);
207
233
  ```
208
234
 
209
235
  ## Design
@@ -215,6 +241,38 @@ const store = inject(STORE);
215
241
 
216
242
  No lifecycle methods. No hooks rules. No context providers. No `useCallback`. Just signals and the DOM.
217
243
 
244
+ ## Progressive Adoption
245
+
246
+ Each module is independent — use as much or as little as you need.
247
+
248
+ ```
249
+ signals.ts ← no deps Use signals anywhere: server, CLI, worker
250
+ shared.ts ← no deps Add typed DI when you need shared state
251
+ logger.ts ← no deps Add logging to your Bun server
252
+ jsx.ts ← signals Add reactive DOM when you need a UI
253
+ routes.ts ← signals Add client-side routing when you need pages
254
+ ```
255
+
256
+ **Level 1 — Reactive state only** (no DOM, no tsconfig changes)
257
+
258
+ ```ts
259
+ import { signal, computed, effect } from "@blueshed/railroad/signals";
260
+ ```
261
+
262
+ **Level 2 — Add JSX** (needs `tsconfig.json` JSX settings)
263
+
264
+ ```ts
265
+ import { signal, createElement, when, list } from "@blueshed/railroad";
266
+ ```
267
+
268
+ **Level 3 — Full app** (signals + JSX + routing + DI + logging)
269
+
270
+ ```ts
271
+ import { signal, routes, inject, createLogger } from "@blueshed/railroad";
272
+ ```
273
+
274
+ Every import path (`/signals`, `/shared`, `/logger`, `/jsx`, `/routes`) works standalone. The barrel export (`@blueshed/railroad`) re-exports everything.
275
+
218
276
  ## Claude Code
219
277
 
220
278
  This package ships with a [Claude Code](https://claude.com/claude-code) skill in `.claude/skills/railroad/`. Copy it into your project so Claude generates correct railroad code — including API usage, patterns, and anti-patterns:
package/jsx.ts CHANGED
@@ -23,32 +23,16 @@
23
23
  * text(fn) — reactive text from computed expression
24
24
  */
25
25
 
26
- import { Signal, signal, effect, computed } from "./signals";
26
+ import { Signal, signal, effect, computed, pushDisposeScope, popDisposeScope, trackDispose } from "./signals";
27
27
  import type { Dispose } from "./signals";
28
28
 
29
+ export { pushDisposeScope, popDisposeScope };
30
+
29
31
  // === SVG namespace ===
30
32
 
31
33
  const SVG_NS = "http://www.w3.org/2000/svg";
32
34
  const storedProps = new WeakMap<Element, Record<string, any>>();
33
35
 
34
- // === Dispose scope management ===
35
-
36
- const disposeStack: Dispose[][] = [];
37
-
38
- export function pushDisposeScope(): void {
39
- disposeStack.push([]);
40
- }
41
-
42
- export function popDisposeScope(): Dispose {
43
- const disposers = disposeStack.pop() || [];
44
- return () => disposers.forEach((d) => d());
45
- }
46
-
47
- function trackDispose(d: Dispose): void {
48
- const scope = disposeStack[disposeStack.length - 1];
49
- if (scope) scope.push(d);
50
- }
51
-
52
36
  // === Fragment ===
53
37
 
54
38
  export function Fragment(props: any): DocumentFragment {
@@ -84,8 +68,10 @@ function applyProps(el: Element, props: Record<string, any>): void {
84
68
  } else {
85
69
  (el as any)[key] = value;
86
70
  }
87
- } else if (key === "style" && typeof value === "object" && !(value instanceof Signal)) {
88
- Object.assign((el as any).style, value);
71
+ } else if (key === "style" && value instanceof Signal) {
72
+ trackDispose(effect(() => { Object.assign((el as HTMLElement).style, value.get()); }));
73
+ } else if (key === "style" && typeof value === "object") {
74
+ Object.assign((el as HTMLElement).style, value);
89
75
  } else if (key.startsWith("on")) {
90
76
  el.addEventListener(key.slice(2).toLowerCase(), value);
91
77
  } else {
package/logger.ts CHANGED
@@ -69,6 +69,14 @@ export function createLogger(tag: string) {
69
69
  type Handler = (req: Request) => Response | Promise<Response>;
70
70
 
71
71
  /** Wrap a route handler with access logging. */
72
+ function safePathname(url: string): string {
73
+ try {
74
+ return new URL(url).pathname;
75
+ } catch {
76
+ return url;
77
+ }
78
+ }
79
+
72
80
  export function loggedRequest(tag: string, handler: Handler): Handler {
73
81
  const log = createLogger(tag);
74
82
  return async (req: Request) => {
@@ -77,13 +85,14 @@ export function loggedRequest(tag: string, handler: Handler): Handler {
77
85
  const res = await handler(req);
78
86
  const ms = (performance.now() - start).toFixed(1);
79
87
  log.info(
80
- `${req.method} ${new URL(req.url).pathname} → ${res.status} (${ms}ms)`,
88
+ `${req.method} ${safePathname(req.url)} → ${res.status} (${ms}ms)`,
81
89
  );
82
90
  return res;
83
- } catch (err: any) {
91
+ } catch (err: unknown) {
84
92
  const ms = (performance.now() - start).toFixed(1);
93
+ const msg = err instanceof Error ? err.message : String(err);
85
94
  log.error(
86
- `${req.method} ${new URL(req.url).pathname} threw (${ms}ms): ${err.message}`,
95
+ `${req.method} ${safePathname(req.url)} threw (${ms}ms): ${msg}`,
87
96
  );
88
97
  throw err;
89
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueshed/railroad",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "Signals, JSX, and routes — a micro UI framework for Bun",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/routes.ts CHANGED
@@ -7,29 +7,47 @@
7
7
  * navigate(path) — set location.hash programmatically
8
8
  * matchRoute(pattern, path) — pure pattern matcher, returns params or null
9
9
  *
10
- * Handlers return a Node to render. The router manages cleanup automatically.
10
+ * Handlers receive (params, params$) and return a Node (sync or async).
11
+ * params — plain object for destructuring: ({ id }) => ...
12
+ * params$ — Signal that updates when params change within the same pattern
13
+ *
14
+ * The router manages cleanup automatically. When params change within the
15
+ * same pattern (e.g. /users/1 → /users/2), params$ updates — no teardown.
11
16
  * routes(app, {
12
17
  * "/": () => <Home />,
13
- * "/site/:id": ({ id }) => <SiteDetail id={id} />,
18
+ * "/site/:id": ({ id }, params$) => <SiteDetail id={id} params$={params$} />,
19
+ * "/status": async () => { const s = await api.get(); return <Status data={s} />; },
14
20
  * });
15
21
  */
16
22
 
17
- import { Signal, computed, effect } from "./signals";
23
+ import { Signal, signal, computed, effect, pushDisposeScope, popDisposeScope } from "./signals";
18
24
  import type { Dispose } from "./signals";
19
- import { pushDisposeScope, popDisposeScope } from "./jsx";
20
25
 
21
26
  let hashSignal: Signal<string> | null = null;
27
+ let hashListenerCount = 0;
28
+ let hashListener: (() => void) | null = null;
22
29
 
23
30
  function getHash(): Signal<string> {
24
31
  if (!hashSignal) {
25
32
  hashSignal = new Signal(location.hash.slice(1) || "/");
26
- window.addEventListener("hashchange", () => {
33
+ hashListener = () => {
27
34
  hashSignal!.set(location.hash.slice(1) || "/");
28
- });
35
+ };
36
+ window.addEventListener("hashchange", hashListener);
29
37
  }
38
+ hashListenerCount++;
30
39
  return hashSignal;
31
40
  }
32
41
 
42
+ function releaseHash(): void {
43
+ hashListenerCount--;
44
+ if (hashListenerCount === 0 && hashListener) {
45
+ window.removeEventListener("hashchange", hashListener);
46
+ hashListener = null;
47
+ hashSignal = null;
48
+ }
49
+ }
50
+
33
51
  export function matchRoute(
34
52
  pattern: string,
35
53
  path: string,
@@ -61,7 +79,10 @@ export function navigate(path: string): void {
61
79
  location.hash = path;
62
80
  }
63
81
 
64
- type RouteHandler = (params: Record<string, string>) => Node;
82
+ type RouteHandler = (
83
+ params: Record<string, string>,
84
+ params$: Signal<Record<string, string>>,
85
+ ) => Node | Promise<Node>;
65
86
 
66
87
  export function routes(
67
88
  target: HTMLElement,
@@ -69,28 +90,54 @@ export function routes(
69
90
  ): Dispose {
70
91
  const hash = getHash();
71
92
  let activePattern: string | null = null;
93
+ let activeParams: Signal<Record<string, string>> | null = null;
72
94
  let activeDispose: Dispose | null = null;
95
+ let runId = 0;
96
+ let asyncPending = false;
73
97
 
74
98
  function teardown() {
99
+ runId++;
100
+ if (asyncPending) {
101
+ popDisposeScope()();
102
+ asyncPending = false;
103
+ }
75
104
  if (activeDispose) activeDispose();
76
105
  activeDispose = null;
77
106
  activePattern = null;
107
+ activeParams = null;
78
108
  target.replaceChildren();
79
109
  }
80
110
 
81
111
  function run(handler: RouteHandler, params: Record<string, string>) {
112
+ const myRunId = ++runId;
113
+ activeParams = signal(params);
82
114
  pushDisposeScope();
83
- const node = handler(params);
84
- activeDispose = popDisposeScope();
85
- target.appendChild(node);
115
+ const result = handler(params, activeParams);
116
+
117
+ if (result instanceof Promise) {
118
+ asyncPending = true;
119
+ result.then((node) => {
120
+ if (myRunId !== runId) return; // navigated away during await
121
+ asyncPending = false;
122
+ activeDispose = popDisposeScope();
123
+ target.appendChild(node);
124
+ });
125
+ } else {
126
+ activeDispose = popDisposeScope();
127
+ target.appendChild(result);
128
+ }
86
129
  }
87
130
 
88
- return effect(() => {
131
+ const disposeEffect = effect(() => {
89
132
  const path = hash.get();
90
133
  for (const [pattern, handler] of Object.entries(table)) {
91
134
  const params = matchRoute(pattern, path);
92
135
  if (params) {
93
- if (pattern === activePattern) return;
136
+ if (pattern === activePattern) {
137
+ // Same pattern, different params — update the signal
138
+ activeParams!.set(params);
139
+ return;
140
+ }
94
141
  teardown();
95
142
  activePattern = pattern;
96
143
  run(handler, params);
@@ -107,4 +154,10 @@ export function routes(
107
154
  }
108
155
  teardown();
109
156
  });
157
+
158
+ return () => {
159
+ disposeEffect();
160
+ teardown();
161
+ releaseHash();
162
+ };
110
163
  }
package/signals.ts CHANGED
@@ -88,7 +88,7 @@ export class Signal<T> {
88
88
  }
89
89
  effectDepth++;
90
90
  try {
91
- if (effectDepth > MAX_EFFECT_DEPTH) {
91
+ if (effectDepth >= MAX_EFFECT_DEPTH) {
92
92
  throw new Error(
93
93
  "Maximum effect depth exceeded — possible infinite loop",
94
94
  );
@@ -142,11 +142,32 @@ export function effect(fn: () => void | (() => void)): () => void {
142
142
  };
143
143
  }
144
144
 
145
+ // === Dispose type & scope management ===
146
+
147
+ export type Dispose = () => void;
148
+
149
+ const disposeStack: Dispose[][] = [];
150
+
151
+ export function pushDisposeScope(): void {
152
+ disposeStack.push([]);
153
+ }
154
+
155
+ export function popDisposeScope(): Dispose {
156
+ const disposers = disposeStack.pop() || [];
157
+ return () => disposers.forEach((d) => d());
158
+ }
159
+
160
+ export function trackDispose(d: Dispose): void {
161
+ const scope = disposeStack[disposeStack.length - 1];
162
+ if (scope) scope.push(d);
163
+ }
164
+
145
165
  // === computed() ===
146
166
 
147
167
  export function computed<T>(fn: () => T): Signal<T> {
148
168
  const s = new Signal<T>(fn());
149
- effect(() => s.set(fn()));
169
+ const dispose = effect(() => s.set(fn()));
170
+ trackDispose(dispose);
150
171
  return s;
151
172
  }
152
173
 
@@ -180,7 +201,3 @@ export function batch(fn: () => void): void {
180
201
  export function signal<T>(initialValue: T): Signal<T> {
181
202
  return new Signal(initialValue);
182
203
  }
183
-
184
- // === Dispose type ===
185
-
186
- export type Dispose = () => void;
@@ -1,325 +0,0 @@
1
- # JSX — Real DOM Elements Backed by Signals
2
-
3
- Railroad's JSX produces real DOM nodes, not a virtual DOM. Components are plain functions called once. Reactivity comes from signals, not re-rendering.
4
-
5
- ## JSX Setup
6
-
7
- Railroad supports two JSX modes. Check the project's `tsconfig.json` to know which is in use.
8
-
9
- ### Automatic runtime (recommended)
10
-
11
- ```json
12
- { "jsx": "react-jsx", "jsxImportSource": "@blueshed/railroad" }
13
- ```
14
-
15
- No imports needed — the compiler auto-inserts the runtime. Just write JSX:
16
-
17
- ```tsx
18
- import { signal } from "@blueshed/railroad";
19
- const count = signal(0);
20
- function App() {
21
- return <div>{count}</div>; // works, no createElement import
22
- }
23
- ```
24
-
25
- ### Classic runtime
26
-
27
- ```json
28
- { "jsx": "react", "jsxFactory": "createElement", "jsxFragmentFactory": "Fragment" }
29
- ```
30
-
31
- Every `.tsx` file must import `createElement` (and `Fragment` for `<>...</>`):
32
-
33
- ```tsx
34
- import { createElement, Fragment } from "@blueshed/railroad";
35
- ```
36
-
37
- ## Components
38
-
39
- A component is a function that receives props and returns a DOM Node. It runs **once** per mount.
40
-
41
- ```tsx
42
- function Greeting({ name }: { name: string }) {
43
- return <h1>Hello {name}</h1>;
44
- }
45
-
46
- // Usage:
47
- <Greeting name="World" />
48
- ```
49
-
50
- ### Children
51
-
52
- Children are passed as part of props:
53
-
54
- ```tsx
55
- function Card({ children }: { children: any[] }) {
56
- return <div class="card">{children}</div>;
57
- }
58
-
59
- <Card><p>Content</p></Card>
60
- ```
61
-
62
- ### Components Run Once
63
-
64
- This is the most important thing to understand. Unlike React, the component function body executes once. To make things reactive, use signals:
65
-
66
- ```tsx
67
- // WRONG — static, never updates:
68
- function Counter() {
69
- let count = 0;
70
- return <p>{count}</p>; // always shows 0
71
- }
72
-
73
- // RIGHT — reactive via signal:
74
- function Counter() {
75
- const count = signal(0);
76
- return (
77
- <div>
78
- <p>{count}</p> {/* auto-updates when count changes */}
79
- <button onclick={() => count.update(n => n + 1)}>+</button>
80
- </div>
81
- );
82
- }
83
- ```
84
-
85
- ## Props and Attributes
86
-
87
- ### Event Handlers
88
-
89
- Lowercase `on` prefix, directly on the element. The handler name after `on` is lowercased and passed to `addEventListener`.
90
-
91
- ```tsx
92
- <button onclick={() => doSomething()}>Click</button>
93
- <input oninput={(e) => query.set(e.target.value)} />
94
- <form onsubmit={(e) => { e.preventDefault(); save(); }}>
95
- ```
96
-
97
- ### Class
98
-
99
- Use `class` or `className` — both work. Signals are supported:
100
-
101
- ```tsx
102
- <div class="active">static</div>
103
- <div class={activeClass}>reactive</div> {/* activeClass is a Signal<string> */}
104
- ```
105
-
106
- For derived/conditional class values, use `computed()`:
107
-
108
- ```tsx
109
- import { computed } from "@blueshed/railroad";
110
-
111
- <button class={computed(() => `btn ${active.get() ? "active" : ""}`)}>
112
- ```
113
-
114
- **Do NOT use `text()` for attributes** — `text()` creates a text DOM node, not a string. Use `computed()` for any reactive attribute value (class, style, data-*, etc.).
115
-
116
- ### Style
117
-
118
- Pass a plain object (not a signal) to set inline styles:
119
-
120
- ```tsx
121
- <div style={{ color: "red", fontSize: "16px" }}>styled</div>
122
- ```
123
-
124
- ### DOM Properties
125
-
126
- These are set as element properties (not attributes) and support signals: `value`, `checked`, `disabled`, `selected`, `src`, `srcdoc`.
127
-
128
- ```tsx
129
- const inputValue = signal("");
130
- <input value={inputValue} oninput={(e) => inputValue.set(e.target.value)} />
131
-
132
- const isDisabled = signal(false);
133
- <button disabled={isDisabled}>Submit</button>
134
- ```
135
-
136
- ### innerHTML
137
-
138
- Set raw HTML (supports signals):
139
-
140
- ```tsx
141
- <div innerHTML={htmlSignal} />
142
- ```
143
-
144
- ### Ref
145
-
146
- Get a reference to the underlying DOM element:
147
-
148
- ```tsx
149
- <input ref={(el) => el.focus()} />
150
- ```
151
-
152
- The ref callback fires immediately after element creation.
153
-
154
- ### Regular Attributes
155
-
156
- Anything not matching the above is set via `setAttribute`. Signals auto-update. `false` and `null`/`undefined` remove the attribute.
157
-
158
- ```tsx
159
- <a href="/about">About</a>
160
- <div data-id={itemId}>...</div> {/* itemId can be a Signal */}
161
- <input aria-label={label} />
162
- ```
163
-
164
- ## Signals as Children
165
-
166
- Pass a signal directly as a child to create a reactive text node:
167
-
168
- ```tsx
169
- const count = signal(0);
170
- <p>Count: {count}</p> // updates automatically
171
- ```
172
-
173
- **Do NOT call `.get()` in JSX children** — this evaluates once and creates a static string:
174
-
175
- ```tsx
176
- // WRONG — static text, never updates:
177
- <p>Count: {count.get()}</p>
178
-
179
- // RIGHT — reactive:
180
- <p>Count: {count}</p>
181
- ```
182
-
183
- ## Null, Boolean, and Undefined Children
184
-
185
- `null`, `undefined`, `true`, and `false` are ignored (not rendered). Use this for conditional inline content:
186
-
187
- ```tsx
188
- <div>{showWarning.peek() && <span>Warning!</span>}</div>
189
- ```
190
-
191
- But prefer `when()` for reactive conditionals — see below.
192
-
193
- ## `text(fn)` — Reactive Computed Text
194
-
195
- For expressions more complex than a single signal, use `text()`:
196
-
197
- ```tsx
198
- import { text } from "@blueshed/railroad";
199
-
200
- <span>{text(() => count.get() > 5 ? "High" : "Low")}</span>
201
- <p>{text(() => `${firstName.get()} ${lastName.get()}`)}</p>
202
- ```
203
-
204
- `text()` creates a computed signal internally and keeps its text node updated.
205
-
206
- ## `when(condition, truthy, falsy?)` — Conditional Rendering
207
-
208
- Swaps DOM nodes when the truthiness of the condition changes.
209
-
210
- ```tsx
211
- import { when } from "@blueshed/railroad";
212
-
213
- // With a signal:
214
- {when(isLoggedIn, () => <Dashboard />, () => <Login />)}
215
-
216
- // With a function (becomes a computed internally):
217
- {when(() => user.get() !== null, () => <Profile />, () => <SignIn />)}
218
- ```
219
-
220
- ### Key Behavior
221
-
222
- - Swaps **only on truthiness transitions** (falsy to truthy, or truthy to falsy).
223
- - Value changes within the same branch (e.g., user changes from one user to another — both truthy) do **not** re-render the branch.
224
- - Components inside each branch should read signals to react to value changes.
225
- - Each branch gets its own dispose scope — effects created inside are cleaned up on swap.
226
-
227
- ## `list(items, keyFn?, render)` — Keyed List Rendering
228
-
229
- Renders a reactive list with DOM diffing by key.
230
-
231
- ```tsx
232
- import { list, text } from "@blueshed/railroad";
233
-
234
- const todos = signal([
235
- { id: 1, text: "Buy milk" },
236
- { id: 2, text: "Walk dog" },
237
- ]);
238
- ```
239
-
240
- ### Keyed form (recommended)
241
-
242
- The render function receives **`Signal<T>`** and **`Signal<number>`** — not raw values. When the array updates and an existing key's value changes, the item signal is updated in place, so effects inside the rendered DOM react automatically without recreating the node.
243
-
244
- ```tsx
245
- {list(todos, (t) => t.id, (todo, idx) =>
246
- <li class={computed(() => todo.get().done ? "done" : "")}>
247
- {text(() => todo.get().text)}
248
- </li>
249
- )}
250
- ```
251
-
252
- Use `.get()` inside `text()`, `computed()`, or `effect()` to subscribe to item changes. Use `.peek()` for one-off reads.
253
-
254
- ### Non-keyed form (index-based, raw values)
255
-
256
- The render function receives the raw item value and index number. Items are recreated when the array changes.
257
-
258
- ```tsx
259
- {list(todos, (t, i) => <li>{t.text}</li>)}
260
- ```
261
-
262
- ### How It Works
263
-
264
- - New items: rendered and inserted.
265
- - Removed items: disposed and removed from DOM.
266
- - Reordered items: moved in the DOM (not re-created).
267
- - **Keyed: changed items** update the existing item `Signal`, triggering reactive updates inside the node.
268
- - Each item gets its own dispose scope.
269
-
270
- ## `Fragment` — Grouping Without a Wrapper
271
-
272
- ```tsx
273
- import { createElement, Fragment } from "@blueshed/railroad";
274
-
275
- function Columns() {
276
- return (
277
- <>
278
- <td>First</td>
279
- <td>Second</td>
280
- </>
281
- );
282
- }
283
- ```
284
-
285
- ## Dispose Scopes
286
-
287
- The JSX layer manages cleanup automatically via dispose scopes. When `routes()` or `when()` swap content, all effects created during that content's rendering are disposed. You can also manage scopes manually:
288
-
289
- ```tsx
290
- import { pushDisposeScope, popDisposeScope } from "@blueshed/railroad";
291
-
292
- pushDisposeScope();
293
- // ... create elements, effects ...
294
- const dispose = popDisposeScope();
295
-
296
- // later, clean up everything:
297
- dispose();
298
- ```
299
-
300
- This is **required** when an effect creates JSX that may contain child effects, event handlers, or WebSocket listeners. Every effect that clears a container and appends new JSX **must** use this pattern:
301
-
302
- ```tsx
303
- let childDispose: (() => void) | null = null;
304
-
305
- effect(() => {
306
- const data = mySignal.get();
307
- if (childDispose) { childDispose(); childDispose = null; }
308
- container.innerHTML = "";
309
- pushDisposeScope();
310
- container.appendChild(<ChildComponent data={data} /> as Node);
311
- childDispose = popDisposeScope();
312
- return () => { if (childDispose) { childDispose(); childDispose = null; } };
313
- });
314
- ```
315
-
316
- ### Why the cleanup return matters
317
-
318
- The effect handles two lifecycle events:
319
-
320
- 1. **Re-run** — when a tracked signal changes, the effect re-runs. The `if (childDispose)` call at the top cleans up the previous child scope before creating a new one.
321
- 2. **Dispose** — when the effect itself is stopped (parent scope torn down, route change, `when()` swap), the cleanup function returned from the effect disposes the child scope. **Without this return, child effects leak when the parent is removed.**
322
-
323
- ### Common mistake
324
-
325
- The most frequent porting bug is forgetting dispose scopes in effects that rebuild DOM. Any effect that does `container.innerHTML = ""; container.appendChild(<SomeJSX /> as Node)` needs this pattern. Static JSX with no signals, effects, or event listeners inside does not need it, but when in doubt, wrap it.
@@ -1,157 +0,0 @@
1
- # Routes — Hash-Based Client Router
2
-
3
- Railroad uses hash-based routing (`#/path`). The router swaps content in a target element and automatically manages dispose scoping — when a route changes, all effects from the previous route are cleaned up.
4
-
5
- ## `routes(target, table)` — Declarative Router
6
-
7
- The main entry point. Pass a DOM element and a route table. Returns a dispose function to stop the router.
8
-
9
- ```tsx
10
- import { createElement, routes } from "@blueshed/railroad";
11
-
12
- const app = document.getElementById("app")!;
13
-
14
- const dispose = routes(app, {
15
- "/": () => <Home />,
16
- "/users/:id": ({ id }) => <UserDetail id={id} />,
17
- "/settings": () => <Settings />,
18
- "*": () => <NotFound />,
19
- });
20
- ```
21
-
22
- ### Route Table
23
-
24
- - Keys are URL patterns (without the `#`).
25
- - Values are handler functions that receive matched params and return a Node.
26
- - Patterns are matched in declaration order — first match wins.
27
- - `*` is a catch-all, checked last.
28
-
29
- ### Pattern Syntax
30
-
31
- - **Exact match:** `/about` matches only `#/about`
32
- - **Params:** `/users/:id` matches `#/users/42` with `{ id: "42" }`
33
- - **Multiple params:** `/users/:id/posts/:pid` matches `#/users/1/posts/99` with `{ id: "1", pid: "99" }`
34
- - **Catch-all:** `*` matches anything not matched by other patterns
35
-
36
- Segments must match exactly in count — `/users/:id` does not match `/users/42/extra`.
37
-
38
- URI components are automatically decoded (`%20` becomes a space, etc.).
39
-
40
- ### Automatic Cleanup
41
-
42
- When the hash changes to a new route:
43
-
44
- 1. The previous route's dispose scope is called (cleaning up all effects).
45
- 2. The target element is cleared (`replaceChildren()`).
46
- 3. A new dispose scope is pushed.
47
- 4. The new handler runs, creating DOM and effects.
48
- 5. The dispose scope is popped and stored for next cleanup.
49
-
50
- If the hash changes but the **same pattern** matches (e.g., `/users/1` to `/users/2`), the route does **not** re-render. The component should use signals or `route()` to react to param changes.
51
-
52
- ## `navigate(path)` — Programmatic Navigation
53
-
54
- ```ts
55
- import { navigate } from "@blueshed/railroad";
56
-
57
- navigate("/users/42"); // sets location.hash = "/users/42"
58
- navigate("/"); // go home
59
- ```
60
-
61
- Use this in event handlers, after form submissions, etc. It triggers a `hashchange` event which the router picks up.
62
-
63
- ### Navigation Links
64
-
65
- For `<a>` tags, use hash hrefs directly:
66
-
67
- ```tsx
68
- <a href="#/users/42">View User</a>
69
- <a href="#/settings">Settings</a>
70
- ```
71
-
72
- Or use `navigate()` in an onclick:
73
-
74
- ```tsx
75
- <button onclick={() => navigate(`/users/${id}`)}>View</button>
76
- ```
77
-
78
- ## `route(pattern)` — Reactive Route Signal
79
-
80
- Returns a `Signal<T | null>` that is non-null when the pattern matches the current hash, null otherwise. Useful for components that need to react to route changes without being inside the router.
81
-
82
- ```ts
83
- import { route } from "@blueshed/railroad";
84
-
85
- const userRoute = route<{ id: string }>("/users/:id");
86
-
87
- effect(() => {
88
- const params = userRoute.get();
89
- if (params) {
90
- console.log(`Viewing user ${params.id}`);
91
- }
92
- });
93
- ```
94
-
95
- This is useful for:
96
- - Highlighting the active nav link.
97
- - Loading data when params change within the same route pattern.
98
- - Reacting to route changes from anywhere in the app.
99
-
100
- ## `matchRoute(pattern, path)` — Pure Pattern Matcher
101
-
102
- A utility function with no side effects. Returns params object on match, `null` on no match.
103
-
104
- ```ts
105
- import { matchRoute } from "@blueshed/railroad";
106
-
107
- matchRoute("/users/:id", "/users/42"); // { id: "42" }
108
- matchRoute("/users/:id", "/users/42/extra"); // null (segment count mismatch)
109
- matchRoute("/about", "/about"); // {}
110
- matchRoute("/about", "/home"); // null
111
- ```
112
-
113
- ## Handling Param Changes Within a Route
114
-
115
- When navigating from `/users/1` to `/users/2`, the router sees the same pattern (`/users/:id`) and does **not** re-render. The component must handle this itself:
116
-
117
- ```tsx
118
- import { createElement, route, when, signal, effect } from "@blueshed/railroad";
119
-
120
- function UserDetail({ id }: { id: string }) {
121
- // Option 1: Use route() signal to track param changes
122
- const userRoute = route<{ id: string }>("/users/:id");
123
- const user = signal<User | null>(null);
124
-
125
- effect(() => {
126
- const params = userRoute.get();
127
- if (params) {
128
- fetchUser(params.id).then(u => user.set(u));
129
- }
130
- });
131
-
132
- return when(user, () => <div>{user.get()!.name}</div>);
133
- }
134
- ```
135
-
136
- ## Server-Side Pattern
137
-
138
- Railroad's route tables mirror Bun.serve's route syntax. A typical app has both:
139
-
140
- ```ts
141
- // server.ts — Bun serves the HTML shell
142
- import home from "./index.html";
143
- Bun.serve({
144
- routes: {
145
- "/": home,
146
- "/api/users": () => Response.json(users),
147
- },
148
- });
149
-
150
- // app.tsx — client-side routing inside the shell
151
- routes(document.getElementById("app")!, {
152
- "/": () => <Home />,
153
- "/users/:id": ({ id }) => <UserDetail id={id} />,
154
- });
155
- ```
156
-
157
- Resources and routes. Server and client. Same pattern.
@@ -1,94 +0,0 @@
1
- # Shared — Typed Dependency Injection
2
-
3
- A minimal provide/inject system for sharing values across modules without prop threading. Uses typed symbol keys for safety.
4
-
5
- ## Creating a Key
6
-
7
- ```ts
8
- import { key } from "@blueshed/railroad";
9
-
10
- const STORE = key<AppStore>("store");
11
- const THEME = key<Signal<string>>("theme");
12
- const API = key<ApiClient>("api");
13
- ```
14
-
15
- `key<T>(name)` creates a unique symbol branded with type `T`. The name is for debugging (shows in error messages).
16
-
17
- ## Providing a Value
18
-
19
- ```ts
20
- import { provide } from "@blueshed/railroad";
21
-
22
- provide(STORE, createStore());
23
- provide(THEME, signal("light"));
24
- provide(API, new ApiClient("/api"));
25
- ```
26
-
27
- Call `provide()` during initialization, before any component calls `inject()`. Values are stored in a global registry.
28
-
29
- ## Injecting a Value
30
-
31
- ```ts
32
- import { inject } from "@blueshed/railroad";
33
-
34
- const store = inject(STORE); // typed as AppStore
35
- const theme = inject(THEME); // typed as Signal<string>
36
- ```
37
-
38
- If no value has been provided for the key, `inject()` throws:
39
-
40
- ```
41
- Error: No provider for store
42
- ```
43
-
44
- ## Typical Pattern
45
-
46
- Define keys in a shared module, provide at app init, inject anywhere:
47
-
48
- ```ts
49
- // keys.ts — shared key definitions
50
- import { key } from "@blueshed/railroad";
51
- import type { Signal } from "@blueshed/railroad";
52
-
53
- export interface AppStore {
54
- user: Signal<User | null>;
55
- todos: Signal<Todo[]>;
56
- }
57
-
58
- export const STORE = key<AppStore>("store");
59
- ```
60
-
61
- ```ts
62
- // main.ts — provide at startup
63
- import { provide, signal } from "@blueshed/railroad";
64
- import { STORE } from "./keys";
65
-
66
- provide(STORE, {
67
- user: signal(null),
68
- todos: signal([]),
69
- });
70
- ```
71
-
72
- ```tsx
73
- // any-component.tsx — inject where needed
74
- import { createElement } from "@blueshed/railroad";
75
- import { inject } from "@blueshed/railroad";
76
- import { STORE } from "./keys";
77
-
78
- function TodoList() {
79
- const { todos } = inject(STORE);
80
- return list(todos, (t) => t.id, (t) => <li>{t.text}</li>);
81
- }
82
- ```
83
-
84
- ## When to Use Shared vs Props
85
-
86
- - **Props** — for data that flows parent-to-child and varies per instance.
87
- - **Shared** — for app-wide services, stores, or configuration that many components need. Avoids threading the same value through every intermediate component.
88
-
89
- ## Important Notes
90
-
91
- - The registry is global and module-scoped. There is one registry per JavaScript realm.
92
- - Keys are symbols, so two calls to `key("store")` produce **different** keys. Export and import a single key instance.
93
- - `provide()` can be called again with the same key to replace the value.
94
- - `inject()` is synchronous — the value must already be provided.
@@ -1,218 +0,0 @@
1
- # Signals — Reactive State
2
-
3
- Signals are the foundation of railroad. Everything reactive flows from them.
4
-
5
- ## Creating Signals
6
-
7
- ```ts
8
- import { signal } from "@blueshed/railroad";
9
-
10
- const count = signal(0); // Signal<number>
11
- const name = signal("World"); // Signal<string>
12
- const items = signal<string[]>([]); // Signal<string[]>
13
- ```
14
-
15
- ## Reading and Writing
16
-
17
- ```ts
18
- count.get() // read (tracks dependency inside effect/computed)
19
- count.set(5) // write (notifies listeners if value changed)
20
- count.update(n => n + 1) // transform and write (caller must return a new value)
21
- count.mutate(v => ...) // clone, mutate in place, notify (see below)
22
- count.patch({ key: v }) // shallow merge for object signals (see below)
23
- count.peek() // read WITHOUT tracking — use for one-off reads outside effects
24
- ```
25
-
26
- ### Equality Check
27
-
28
- `set()` uses `Object.is()` to compare old and new values. If they are the same, no listeners are notified. This means:
29
-
30
- - Primitives: setting the same number/string/boolean is a no-op.
31
- - Objects/arrays: you must create a new reference to trigger updates.
32
-
33
- ```ts
34
- const todos = signal([{ id: 1, text: "Buy milk" }]);
35
-
36
- // WRONG — same array reference, no update:
37
- todos.peek().push({ id: 2, text: "Walk dog" });
38
- todos.set(todos.peek()); // Object.is says same reference, listeners NOT notified
39
-
40
- // RIGHT — new array:
41
- todos.update(arr => [...arr, { id: 2, text: "Walk dog" }]);
42
- ```
43
-
44
- ### `mutate(fn)` — In-Place Mutation
45
-
46
- `mutate()` clones the current value with `structuredClone`, passes the clone to your function for in-place mutation, then notifies listeners. Use it when you want to modify objects or arrays naturally without manually creating a new reference.
47
-
48
- ```ts
49
- const todos = signal([{ id: 1, text: "Buy milk" }]);
50
-
51
- // Append:
52
- todos.mutate(arr => arr.push({ id: 2, text: "Walk dog" }));
53
-
54
- // Modify nested property:
55
- const doc = signal({ title: "Draft", meta: { tags: ["a"] } });
56
- doc.mutate(d => d.meta.tags.push("b"));
57
-
58
- // Toggle in a Set:
59
- const selected = signal(new Set([1, 2, 3]));
60
- selected.mutate(s => s.has(4) ? s.delete(4) : s.add(4));
61
- ```
62
-
63
- `mutate()` always notifies listeners (the clone guarantees a new reference). Use `update()` when you can return a new value cheaply; use `mutate()` when in-place mutation is more natural.
64
-
65
- ### `patch(partial)` — Shallow Merge
66
-
67
- `patch()` does a shallow merge for object signals — equivalent to `set({ ...current, ...partial })`:
68
-
69
- ```ts
70
- const filter = signal({ color: "red", size: 10, active: true });
71
-
72
- filter.patch({ color: "blue" }); // { color: "blue", size: 10, active: true }
73
- filter.patch({ size: 20, active: false }); // { color: "blue", size: 20, active: false }
74
- ```
75
-
76
- Like `set()`, `patch()` uses `Object.is` — since the spread always creates a new reference, listeners are always notified.
77
-
78
- ## Computed Signals
79
-
80
- Derived read-only signals that auto-update when dependencies change.
81
-
82
- ```ts
83
- import { computed } from "@blueshed/railroad";
84
-
85
- const firstName = signal("John");
86
- const lastName = signal("Doe");
87
- const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
88
-
89
- fullName.get(); // "John Doe"
90
- firstName.set("Jane");
91
- fullName.get(); // "Jane Doe"
92
- ```
93
-
94
- Computed signals chain:
95
-
96
- ```ts
97
- const a = signal(1);
98
- const b = computed(() => a.get() + 1); // 2
99
- const c = computed(() => b.get() * 10); // 20
100
- a.set(3);
101
- c.get(); // 40
102
- ```
103
-
104
- ## Effects
105
-
106
- Run a function whenever its signal dependencies change. The function runs immediately on creation.
107
-
108
- ```ts
109
- import { effect } from "@blueshed/railroad";
110
-
111
- const count = signal(0);
112
-
113
- const dispose = effect(() => {
114
- console.log(`count is ${count.get()}`);
115
- });
116
- // logs: "count is 0"
117
-
118
- count.set(1);
119
- // logs: "count is 1"
120
-
121
- dispose(); // stop listening
122
- count.set(2); // nothing logged
123
- ```
124
-
125
- ### Cleanup Functions
126
-
127
- An effect can return a cleanup function. It runs before each re-execution and on dispose.
128
-
129
- ```ts
130
- effect(() => {
131
- const id = setInterval(() => console.log(count.get()), 1000);
132
- return () => clearInterval(id); // cleanup before re-run or on dispose
133
- });
134
- ```
135
-
136
- ### Automatic Dependency Tracking
137
-
138
- Effects only track signals read during execution. If a branch is not taken, those signals are not tracked:
139
-
140
- ```ts
141
- const showDetail = signal(true);
142
- const summary = signal("short");
143
- const detail = signal("long");
144
-
145
- effect(() => {
146
- if (showDetail.get()) {
147
- console.log(detail.get()); // tracked
148
- } else {
149
- console.log(summary.get()); // tracked only when showDetail is false
150
- }
151
- });
152
-
153
- // When showDetail is true, changing summary does NOT re-run the effect.
154
- ```
155
-
156
- ### Infinite Loop Protection
157
-
158
- Effects are guarded against infinite loops (max depth 100). If an effect sets a signal that triggers itself in a cycle, it throws:
159
-
160
- ```
161
- Error: Maximum effect depth exceeded — possible infinite loop
162
- ```
163
-
164
- ## Batch
165
-
166
- Group multiple signal writes so effects run only once at the end.
167
-
168
- ```ts
169
- import { batch } from "@blueshed/railroad";
170
-
171
- const a = signal(1);
172
- const b = signal(2);
173
-
174
- effect(() => {
175
- console.log(a.get() + b.get());
176
- });
177
- // logs: 3
178
-
179
- batch(() => {
180
- a.set(10);
181
- b.set(20);
182
- });
183
- // logs: 30 (once, not twice)
184
- ```
185
-
186
- Batches nest safely — effects flush only when the outermost batch completes.
187
-
188
- ## Dispose Pattern
189
-
190
- `effect()` returns a dispose function. Call it to stop the effect and run its cleanup.
191
-
192
- The JSX layer manages dispose scopes automatically — effects created during component rendering are collected and disposed when the component is removed (e.g., by the router or `when()`). You rarely need to manage dispose manually unless you create effects outside of JSX rendering.
193
-
194
- ```ts
195
- // Manual dispose (outside JSX):
196
- const dispose = effect(() => { ... });
197
- // later:
198
- dispose();
199
-
200
- // Inside JSX components, effects are auto-collected.
201
- // The router or when() calls dispose when swapping content.
202
- ```
203
-
204
- ## Where to Declare Signals
205
-
206
- - **Module level** — shared state, lives for the app lifetime. Good for stores, global UI state.
207
- - **Inside a component** — local state, created fresh each time the component mounts. Disposed when the component is removed.
208
-
209
- ```ts
210
- // Module level — shared, persistent
211
- const currentUser = signal<User | null>(null);
212
-
213
- // Component level — local, ephemeral
214
- function SearchBox() {
215
- const query = signal("");
216
- return <input value={query} oninput={(e) => query.set(e.target.value)} />;
217
- }
218
- ```