graftjs 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,576 @@
1
+ ```
2
+ ▄▄▄▄
3
+ ██▀▀▀ ██
4
+ ▄███▄██ ██▄████ ▄█████▄ ███████ ███████
5
+ ██▀ ▀██ ██▀ ▀ ▄▄▄██ ██ ██
6
+ ██ ██ ██ ▄██▀▀▀██ ██ ██
7
+ ▀██▄▄███ ██ ██▄▄▄███ ██ ██▄▄▄
8
+ ▄▀▀▀ ██ ▀▀ ▀▀▀▀ ▀▀ ▀▀ ▀▀▀▀
9
+ ▀████▀▀
10
+ ```
11
+
12
+ *The smallest API imaginable.*
13
+
14
+ # graft
15
+
16
+ Compose React components by wiring named parameters together.
17
+
18
+ `compose({ into, from, key })` feeds `from`'s output into `into`'s input named `key`. The remaining unsatisfied inputs bubble up as the composed component's props. The result is always a standard React component.
19
+
20
+ No prop drilling. No Context. No useState. No useEffect. No manual subscriptions.
21
+
22
+ ```
23
+ npm install graft
24
+ ```
25
+
26
+ ## Why
27
+
28
+ React components are functions with named parameters (props). When you build a UI, you're really building a graph of data dependencies between those functions. But React forces you to wire that graph imperatively — passing props down, lifting state up, wrapping in Context providers, sprinkling hooks everywhere.
29
+
30
+ Graft lets you describe the wiring directly. You say *what* feeds into *what*, and the library builds the component for you. The unsatisfied inputs become the new component's props. This is [graph programming](https://uriva.github.io/blog/graph-programming.html) applied to React.
31
+
32
+ Graft drastically reduces the tokens needed to construct something, and drastically reduces the number of possible bugs.
33
+
34
+ ## Core concepts
35
+
36
+ There are only two things: **components** and **compose**.
37
+
38
+ A **component** is a typed function from inputs to an output. If the output is a `ReactElement`, it renders UI. If the output is a `number`, `string`, `object`, etc., it's a data source. There is no separate "provider" concept — everything is a component.
39
+
40
+ A **source** is a component with no inputs that pushes values over time — a WebSocket, a timer, a browser API. It's one way to introduce reactivity. Everything downstream re-runs automatically when a source emits.
41
+
42
+ **`state`** is a global mutable cell. It returns a `[Component, setter]` tuple. The component behaves like a source (no inputs, emits values), and the setter can be called from anywhere — event handlers, outside the graph, wherever. New subscribers receive the current value immediately.
43
+
44
+ **`instantiate`** creates an isolated copy of a subgraph. It takes a template — a function that builds and returns a component — and returns a new component. Each subscription gets its own fresh instance, so any `state()` or `source()` calls inside the template produce independent cells. This is how you get local state.
45
+
46
+ **`compose({ into, from, key })`** wires `from`'s output into `into`'s input named `key`. Returns a new component whose inputs are `into`'s remaining inputs plus `from`'s inputs.
47
+
48
+ When you're done composing, **`toReact`** converts the result into a regular `React.FC` (requires the output to be `ReactElement`).
49
+
50
+ ### "The" vs "a"
51
+
52
+ In React, everything is **"a"** by default. Each render creates *a* counter, *a* form, *a* piece of state. Multiple instances are the norm — you get isolation for free via hooks.
53
+
54
+ Graft defaults to **"the"**. `state()` creates *the* cell. `source()` creates *the* stream. There is exactly one, and every subscriber sees the same value. Definiteness is the default.
55
+
56
+ `instantiate()` is how you say **"a"** — it's the explicit opt-in to indefinite instances. Each subscription gets its own independent copy of the subgraph, with its own state cells and source subscriptions.
57
+
58
+ ## Quick example
59
+
60
+ A live crypto price card. The price streams over Binance's public WebSocket, the coin name is fetched async from CoinGecko, and a header embeds as a child View inside the card layout. All real, no API keys.
61
+
62
+ ```mermaid
63
+ graph BT
64
+ App["<App coinId="bitcoin" />"] -- coinId --> CoinName
65
+ CoinName -- name --> Header
66
+ Header -- header --> PriceCard
67
+ PriceFeed -- price --> FormatPrice
68
+ FormatPrice -- displayPrice --> PriceCard
69
+ PriceCard -- View --> Output((" "))
70
+
71
+ style Output fill:none,stroke:none
72
+ ```
73
+
74
+ ```tsx
75
+ import { z } from "zod/v4";
76
+ import { component, compose, source, toReact, View } from "graft";
77
+
78
+ // A live price feed — pushes new values over a public WebSocket.
79
+ // source() is the only way to introduce reactivity into a graft graph.
80
+ // Everything downstream re-runs automatically when it emits.
81
+ const PriceFeed = source({
82
+ output: z.number(),
83
+ run: (emit) => {
84
+ const ws = new WebSocket("wss://stream.binance.com:9443/ws/btcusdt@trade");
85
+ ws.onmessage = (e) => emit(Number(JSON.parse(e.data).p));
86
+ return () => ws.close();
87
+ },
88
+ });
89
+
90
+ // An async data component — fetches the coin name from CoinGecko.
91
+ // The run function is async. compose handles this automatically.
92
+ const CoinName = component({
93
+ input: z.object({ coinId: z.string() }),
94
+ output: z.string(),
95
+ run: async ({ coinId }) => {
96
+ const res = await fetch(
97
+ `https://api.coingecko.com/api/v3/coins/${coinId}?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false`,
98
+ );
99
+ return (await res.json()).name;
100
+ },
101
+ });
102
+
103
+ // A pure data component — formats a number into a display string.
104
+ const FormatPrice = component({
105
+ input: z.object({ price: z.number() }),
106
+ output: z.string(),
107
+ run: ({ price }) =>
108
+ new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(price),
109
+ });
110
+
111
+ // A header component — returns a View.
112
+ const Header = component({
113
+ input: z.object({ name: z.string() }),
114
+ output: View,
115
+ run: ({ name }) => <h1>{name}</h1>,
116
+ });
117
+
118
+ // The page layout — accepts a View for the header slot and a string for the price.
119
+ // It doesn't know where any of its inputs come from.
120
+ const PriceCard = component({
121
+ input: z.object({ header: View, displayPrice: z.string() }),
122
+ output: View,
123
+ run: ({ header, displayPrice }) => (
124
+ <div>
125
+ {header}
126
+ <span>{displayPrice}</span>
127
+ </div>
128
+ ),
129
+ });
130
+
131
+ // --- Wiring ---
132
+
133
+ // PriceFeed → FormatPrice → PriceCard.displayPrice
134
+ const LivePrice = compose({ into: FormatPrice, from: PriceFeed, key: "price" });
135
+ const WithPrice = compose({ into: PriceCard, from: LivePrice, key: "displayPrice" });
136
+
137
+ // CoinName (async) → Header → PriceCard.header
138
+ const NamedHeader = compose({ into: Header, from: CoinName, key: "name" });
139
+ const App = toReact(
140
+ compose({ into: WithPrice, from: NamedHeader, key: "header" }),
141
+ );
142
+
143
+ // One prop left — everything else is wired internally.
144
+ // Renders nothing while CoinGecko loads, then shows the card.
145
+ // When Binance pushes a new trade, only the price path re-runs.
146
+ <App coinId="bitcoin" />
147
+ ```
148
+
149
+ <p align="center">
150
+ <img src="demo.gif" alt="Live crypto price card demo" width="600" />
151
+ </p>
152
+
153
+ ## API
154
+
155
+ ### `component({ input, output, run })`
156
+
157
+ Define a component from a zod input schema, a zod output schema, and a function.
158
+
159
+ ```tsx
160
+ import { z } from "zod/v4";
161
+ import { component, View } from "graft";
162
+
163
+ // A visual component (output is ReactElement)
164
+ const UserCard = component({
165
+ input: z.object({
166
+ name: z.string(),
167
+ email: z.string(),
168
+ age: z.number(),
169
+ }),
170
+ output: View,
171
+ run: ({ name, email, age }) => (
172
+ <div>
173
+ <h2>{name}</h2>
174
+ <p>{email}</p>
175
+ <p>{age} years old</p>
176
+ </div>
177
+ ),
178
+ });
179
+
180
+ // A data component (output is a number)
181
+ const FetchAge = component({
182
+ input: z.object({ userId: z.string() }),
183
+ output: z.number(),
184
+ run: ({ userId }) => lookupAge(userId),
185
+ });
186
+ ```
187
+
188
+ The input schema is the source of truth for both TypeScript types and runtime validation. The output schema declares the type of value the component produces. Use `View` for components that return JSX.
189
+
190
+ ### `compose({ into, from, key })`
191
+
192
+ Wire `from`'s output into `into`'s input named `key`. Returns a new component.
193
+
194
+ ```tsx
195
+ import { compose } from "graft";
196
+
197
+ // UserCard needs { name, email, age }
198
+ // FetchAge needs { userId } and produces a number
199
+ // After composing on "age":
200
+ // → new component needs { name, email, userId }
201
+ const UserCardWithAge = compose({ into: UserCard, from: FetchAge, key: "age" });
202
+ ```
203
+
204
+ The key insight: `"age"` disappears from the inputs because it's now satisfied internally. `"userId"` appears because FetchAge needs it and nobody provides it yet.
205
+
206
+ ### `toReact(graftComponent)`
207
+
208
+ Convert a graft component into a standard `React.FC`. This is the boundary between graft and React. The component must produce a `ReactElement`. Props are validated at runtime — a `ZodError` is thrown if anything is missing or has the wrong type.
209
+
210
+ ```tsx
211
+ import { toReact } from "graft";
212
+
213
+ const UserCardReact = toReact(UserCardWithAge);
214
+
215
+ // TypeScript knows the props are { name: string, email: string, userId: string }
216
+ <UserCardReact name="Alice" email="alice@example.com" userId="u123" />
217
+ ```
218
+
219
+ ### `source({ output, run })`
220
+
221
+ Create a push-based data source with no inputs. This is the only way to introduce reactivity into a graft graph. The `run` function receives an `emit` callback and returns a cleanup function.
222
+
223
+ ```tsx
224
+ import { z } from "zod/v4";
225
+ import { source } from "graft";
226
+
227
+ const GeoLocation = source({
228
+ output: z.object({ lat: z.number(), lng: z.number() }),
229
+ run: (emit) => {
230
+ const id = navigator.geolocation.watchPosition((pos) =>
231
+ emit({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
232
+ );
233
+ return () => navigator.geolocation.clearWatch(id);
234
+ },
235
+ });
236
+ ```
237
+
238
+ When a source emits a new value, every downstream component that depends on it re-runs automatically. Cleanup is called when the React component unmounts.
239
+
240
+ ### `state({ schema, initial })`
241
+
242
+ Create a global mutable state cell. Returns a `[Component, setter]` tuple.
243
+
244
+ ```tsx
245
+ import { z } from "zod/v4";
246
+ import { state, component, compose, toReact, View } from "graft";
247
+
248
+ const [CurrentUser, setCurrentUser] = state({
249
+ schema: z.string(),
250
+ initial: "anonymous",
251
+ });
252
+
253
+ const Greeting = component({
254
+ input: z.object({ name: z.string() }),
255
+ output: View,
256
+ run: ({ name }) => <h1>Hello, {name}</h1>,
257
+ });
258
+
259
+ const App = toReact(
260
+ compose({ into: Greeting, from: CurrentUser, key: "name" }),
261
+ );
262
+
263
+ // Renders: <h1>Hello, anonymous</h1>
264
+ // Then call from anywhere:
265
+ setCurrentUser("Alice");
266
+ // Re-renders: <h1>Hello, Alice</h1>
267
+ ```
268
+
269
+ The component behaves like a source — no inputs, emits values, works with `compose`. The setter is a plain function you can call from event handlers, callbacks, or anywhere else. New subscribers immediately receive the current value, so they never start empty.
270
+
271
+ Note: `state` is global — all usages share the same cell. If you need local state (e.g., two independent form fields from the same template), use `instantiate`.
272
+
273
+ ### `instantiate(template)`
274
+
275
+ Create an isolated instance of a subgraph. The template is a function that builds and returns a component. Each call to `instantiate` creates independent `state()` cells and `source()` subscriptions.
276
+
277
+ **When to use `instantiate` vs a plain `component`:**
278
+
279
+ A `component` is a pure function — same inputs, same output. It has no internal state. If you only need to transform data or render props into JSX, use `component`. That's most of the time.
280
+
281
+ Use `instantiate` when a piece of your graph needs **its own mutable state** — state that is local to that usage, not shared globally. The typical case is reusable UI elements: form fields, toggles, expandable sections, anything where each usage needs independent internal state.
282
+
283
+ ```tsx
284
+ import { z } from "zod/v4";
285
+ import { component, compose, instantiate, state, toReact, View } from "graft";
286
+
287
+ // A template: a function that builds a subgraph.
288
+ // Each call creates fresh state() cells.
289
+ const TextField = () => {
290
+ const [Value, setValue] = state({ schema: z.string(), initial: "" });
291
+
292
+ const Input = component({
293
+ input: z.object({ label: z.string(), text: z.string() }),
294
+ output: View,
295
+ run: ({ label, text }) => (
296
+ <label>
297
+ {label}
298
+ <input value={text} onChange={(e) => setValue(e.target.value)} />
299
+ </label>
300
+ ),
301
+ });
302
+
303
+ return compose({ into: Input, from: Value, key: "text" });
304
+ };
305
+
306
+ // Two independent instances — each has its own Value state cell.
307
+ const NameField = instantiate(TextField);
308
+ const EmailField = instantiate(TextField);
309
+
310
+ const Form = component({
311
+ input: z.object({ name: View, email: View }),
312
+ output: View,
313
+ run: ({ name, email }) => (
314
+ <form>
315
+ {name}
316
+ {email}
317
+ </form>
318
+ ),
319
+ });
320
+
321
+ const WithName = compose({ into: Form, from: NameField, key: "name" });
322
+ const App = toReact(
323
+ compose({ into: WithName, from: EmailField, key: "email" }),
324
+ );
325
+
326
+ // Each field maintains its own text value independently.
327
+ <App label="Name" />
328
+ ```
329
+
330
+ Without `instantiate`, both fields would share the same `state()` cell — typing in one would update both. `instantiate` ensures each usage gets its own isolated copy of the entire subgraph.
331
+
332
+ ### `View`
333
+
334
+ A pre-built zod schema for `ReactElement` output. Use it as the `output` for any component that returns JSX.
335
+
336
+ ```tsx
337
+ import { View } from "graft";
338
+
339
+ const MyComponent = component({
340
+ input: z.object({ text: z.string() }),
341
+ output: View,
342
+ run: ({ text }) => <p>{text}</p>,
343
+ });
344
+ ```
345
+
346
+ ### `GraftLoading`
347
+
348
+ A unique symbol sentinel. Emitted by `subscribe()` when an async component or lazy source hasn't produced a value yet. See [Loading and error states](#loading-and-error-states).
349
+
350
+ ### `isGraftError(value)`
351
+
352
+ Type guard that checks if a value is a `GraftError` sentinel. Returns `true` for error objects produced by async component rejections. See [Loading and error states](#loading-and-error-states).
353
+
354
+ ## Chaining compositions
355
+
356
+ `compose` returns a graft component, so you can compose again. Each step satisfies one more input, and the unsatisfied inputs keep bubbling up.
357
+
358
+ ```tsx
359
+ const Display = component({
360
+ input: z.object({ msg: z.string() }),
361
+ output: View,
362
+ run: ({ msg }) => <p>{msg}</p>,
363
+ });
364
+
365
+ const Format = component({
366
+ input: z.object({ greeting: z.string(), name: z.string() }),
367
+ output: z.string(),
368
+ run: ({ greeting, name }) => `${greeting}, ${name}!`,
369
+ });
370
+
371
+ const MakeGreeting = component({
372
+ input: z.object({ prefix: z.string() }),
373
+ output: z.string(),
374
+ run: ({ prefix }) => `${prefix} says`,
375
+ });
376
+
377
+ // Step 1: Format feeds into Display's "msg"
378
+ // Remaining inputs: { greeting, name }
379
+ const Step1 = compose({ into: Display, from: Format, key: "msg" });
380
+
381
+ // Step 2: MakeGreeting feeds into Step1's "greeting"
382
+ // Remaining inputs: { prefix, name }
383
+ const Step2 = compose({ into: Step1, from: MakeGreeting, key: "greeting" });
384
+
385
+ const App = toReact(Step2);
386
+
387
+ <App prefix="Alice" name="Bob" />
388
+ // Renders: <p>Alice says, Bob!</p>
389
+ ```
390
+
391
+ Each `compose` call is like drawing one edge in a dependency graph. The final component is the whole graph, with only the unconnected inputs exposed as props.
392
+
393
+ ## A more realistic example
394
+
395
+ Consider a user profile page that needs data from multiple sources:
396
+
397
+ ```tsx
398
+ // --- Visual component ---
399
+
400
+ const ProfilePage = component({
401
+ input: z.object({
402
+ name: z.string(),
403
+ email: z.string(),
404
+ postCount: z.number(),
405
+ avatarUrl: z.string(),
406
+ }),
407
+ output: View,
408
+ run: ({ name, email, postCount, avatarUrl }) => (
409
+ <div>
410
+ <img src={avatarUrl} alt={name} />
411
+ <h1>{name}</h1>
412
+ <p>{email}</p>
413
+ <p>{postCount} posts</p>
414
+ </div>
415
+ ),
416
+ });
417
+
418
+ // --- Data components ---
419
+
420
+ const UserInfo = component({
421
+ input: z.object({ userId: z.string() }),
422
+ output: z.object({ name: z.string(), email: z.string() }),
423
+ run: ({ userId }) => db.getUser(userId),
424
+ });
425
+
426
+ const PostCount = component({
427
+ input: z.object({ userId: z.string() }),
428
+ output: z.number(),
429
+ run: ({ userId }) => db.countPosts(userId),
430
+ });
431
+
432
+ const Avatar = component({
433
+ input: z.object({ email: z.string() }),
434
+ output: z.string(),
435
+ run: ({ email }) => `https://gravatar.com/avatar/${hash(email)}`,
436
+ });
437
+
438
+ const ExtractName = component({
439
+ input: z.object({ userInfo: z.object({ name: z.string(), email: z.string() }) }),
440
+ output: z.string(),
441
+ run: ({ userInfo }) => userInfo.name,
442
+ });
443
+
444
+ const ExtractEmail = component({
445
+ input: z.object({ userInfo: z.object({ name: z.string(), email: z.string() }) }),
446
+ output: z.string(),
447
+ run: ({ userInfo }) => userInfo.email,
448
+ });
449
+
450
+ // --- Wiring ---
451
+
452
+ const WithPostCount = compose({ into: ProfilePage, from: PostCount, key: "postCount" });
453
+ // Inputs: { name, email, avatarUrl, userId }
454
+
455
+ const WithAvatar = compose({ into: WithPostCount, from: Avatar, key: "avatarUrl" });
456
+ // Inputs: { name, userId, email }
457
+
458
+ const WithName = compose({ into: WithAvatar, from: ExtractName, key: "name" });
459
+ // Inputs: { userId, email, userInfo }
460
+
461
+ const WithEmail = compose({ into: WithName, from: ExtractEmail, key: "email" });
462
+ // Inputs: { userId, userInfo }
463
+
464
+ const WithUserInfo = compose({ into: WithEmail, from: UserInfo, key: "userInfo" });
465
+ // Inputs: { userId }
466
+
467
+ const ProfilePageReact = toReact(WithUserInfo);
468
+
469
+ // The only input left is userId — everything else is wired internally
470
+ <ProfilePageReact userId="u123" />
471
+ ```
472
+
473
+ ## Runtime validation
474
+
475
+ Every input is validated at runtime using the zod schemas you defined. If an input is missing or has the wrong type, you get a clear `ZodError` at render time — not a silent `undefined` propagating through your component tree.
476
+
477
+ ```tsx
478
+ const App = toReact(
479
+ component({
480
+ input: z.object({ count: z.number() }),
481
+ output: View,
482
+ run: ({ count }) => <span>{count}</span>,
483
+ }),
484
+ );
485
+
486
+ // This throws a ZodError at runtime:
487
+ <App count="not a number" />
488
+ // ZodError: Invalid input: expected number, received string
489
+ ```
490
+
491
+ ## Loading and error states
492
+
493
+ When a component in a graph is async, the graph needs to handle the time before the value arrives and the case where it fails. Graft does this with two sentinel values that flow through the graph like regular data.
494
+
495
+ ### `GraftLoading`
496
+
497
+ A unique symbol emitted by `subscribe()` when a value is not yet available:
498
+
499
+ - **Async components** emit `GraftLoading` immediately, then the resolved value.
500
+ - **Sources** that don't call `emit` synchronously in their `run` function emit `GraftLoading` until the first `emit`.
501
+ - **Sync components** and **state** never emit `GraftLoading` — they always have a value immediately.
502
+
503
+ When `compose` sees `GraftLoading` from `from`, it short-circuits — `into`'s `run` is **not** called, and `GraftLoading` is passed through to the outer subscriber. This means loading propagates through the entire chain automatically.
504
+
505
+ `toReact` renders `null` for `GraftLoading`.
506
+
507
+ ### `GraftError`
508
+
509
+ A branded object `{ _tag: Symbol("GraftError"), error: unknown }` that carries a caught error through the graph:
510
+
511
+ - **Async components** that reject produce a `GraftError` containing the rejection reason.
512
+ - Like `GraftLoading`, errors short-circuit through `compose` without calling downstream `run` functions.
513
+ - `toReact` renders `null` for `GraftError`.
514
+
515
+ ```tsx
516
+ import { GraftLoading, isGraftError } from "graft";
517
+
518
+ const AsyncData = component({
519
+ input: z.object({ id: z.string() }),
520
+ output: z.number(),
521
+ run: async ({ id }) => {
522
+ const res = await fetch(`/api/data/${id}`);
523
+ if (!res.ok) throw new Error("fetch failed");
524
+ return (await res.json()).value;
525
+ },
526
+ });
527
+
528
+ // subscribe() lets you observe the full lifecycle:
529
+ AsyncData.subscribe({ id: "123" }, (value) => {
530
+ if (value === GraftLoading) {
531
+ // Still loading...
532
+ } else if (isGraftError(value)) {
533
+ console.error("Error:", value.error);
534
+ } else {
535
+ console.log("Got value:", value);
536
+ }
537
+ });
538
+ ```
539
+
540
+ The `run()` function is unaffected — it returns the raw `Promise` that rejects normally. The sentinel system only applies to `subscribe()` and `toReact()`.
541
+
542
+ **Note:** `state()` never produces sentinels. It always has a value (the initial value), and the setter updates it synchronously. This is by design — state is always "ready".
543
+
544
+ ## Deduplication
545
+
546
+ `compose` deduplicates emissions from `from` using reference equality (`===`). When `from` emits the same value it emitted last time, the re-subscription to `into` is skipped entirely — `into`'s `run` is never called, and no value propagates downstream.
547
+
548
+ This means:
549
+ - A source spamming the same primitive (e.g., a timer re-emitting `0`) doesn't cause unnecessary work.
550
+ - Calling a state setter with the current value is a no-op for downstream subscribers.
551
+ - Consecutive `GraftLoading` sentinels are collapsed into one.
552
+
553
+ Reference equality is intentional. Two different objects with identical content (`{ x: 1 } !== { x: 1 }`) are *not* deduped — this matches React's behavior and avoids the cost and surprises of deep comparison.
554
+
555
+ ## How it works
556
+
557
+ Graft is a runtime library, not a compiler plugin. `compose()` is a regular function call that:
558
+
559
+ 1. Takes `into`'s input schema and removes the key being wired
560
+ 2. Merges the remaining shape with `from`'s input schema
561
+ 3. Returns a new graft component with the merged schema
562
+ 4. At render time, splits the incoming props, runs `from`, and passes the result to `into`
563
+
564
+ The type-level generics ensure TypeScript knows exactly what inputs the composed component needs. The runtime zod validation ensures the types are enforced even in JavaScript or at module boundaries.
565
+
566
+ ## Install
567
+
568
+ ```
569
+ npm install graft
570
+ ```
571
+
572
+ Requires React 18+ as a peer dependency. Uses [zod v4](https://zod.dev) (`zod/v4` import) for schemas.
573
+
574
+ ## License
575
+
576
+ MIT
@@ -0,0 +1,14 @@
1
+ import { z } from "zod/v4";
2
+ import { type GraftComponent, type MaybePromise } from "./types.js";
3
+ /**
4
+ * Define a graft component from an input schema, output schema, and a function.
5
+ *
6
+ * If the function returns JSX, this is a visual component.
7
+ * If it returns data, this is a data source.
8
+ * The run function may be sync or async — compose handles both.
9
+ */
10
+ export declare function component<S extends z.ZodObject<z.ZodRawShape>, O>({ input, output, run }: {
11
+ input: S;
12
+ output: z.ZodType<O>;
13
+ run: (props: z.infer<S>) => MaybePromise<O>;
14
+ }): GraftComponent<S, O>;
@@ -0,0 +1,30 @@
1
+ import { GraftLoading, graftError } from "./types.js";
2
+ function isPromise(value) {
3
+ return (value !== null &&
4
+ typeof value === "object" &&
5
+ typeof value.then === "function");
6
+ }
7
+ /**
8
+ * Define a graft component from an input schema, output schema, and a function.
9
+ *
10
+ * If the function returns JSX, this is a visual component.
11
+ * If it returns data, this is a data source.
12
+ * The run function may be sync or async — compose handles both.
13
+ */
14
+ export function component({ input, output, run }) {
15
+ const subscribe = (props, cb) => {
16
+ let cancelled = false;
17
+ const result = run(props);
18
+ if (isPromise(result)) {
19
+ cb(GraftLoading);
20
+ result.then((v) => { if (!cancelled)
21
+ cb(v); }, (err) => { if (!cancelled)
22
+ cb(graftError(err)); });
23
+ }
24
+ else {
25
+ cb(result);
26
+ }
27
+ return () => { cancelled = true; };
28
+ };
29
+ return { _tag: "graft-component", schema: input, outputSchema: output, run, subscribe };
30
+ }
@@ -0,0 +1,37 @@
1
+ import React, { type ReactElement } from "react";
2
+ import { z } from "zod/v4";
3
+ import { type GraftComponent } from "./types.js";
4
+ /**
5
+ * compose({ into, from, key }):
6
+ * - into: a component with inputs SA that produces OA
7
+ * - from: a component with inputs SB that produces OB
8
+ * - key: an input name of `into` whose type matches OB
9
+ * - Result: a component whose inputs are SA minus key, plus SB,
10
+ * and whose output is OA
11
+ *
12
+ * If either `from` or `into` is async, the composed run is async.
13
+ * `from`'s output feeds into `into[key]`, remaining params bubble up.
14
+ *
15
+ * subscribe() propagates reactivity: subscribes to `from`, and whenever
16
+ * `from` emits, re-subscribes to `into` with the new value, forwarding
17
+ * `into`'s emissions to the outer callback.
18
+ *
19
+ * If `from` emits GraftLoading or GraftError, compose short-circuits —
20
+ * it passes the sentinel directly to the outer callback without calling
21
+ * `into`'s run/subscribe.
22
+ */
23
+ export declare function compose<SA extends z.ZodObject<z.ZodRawShape>, SB extends z.ZodObject<z.ZodRawShape>, K extends string & keyof z.infer<SA>, OA, OB>({ into, from, key }: {
24
+ into: GraftComponent<SA, OA>;
25
+ from: GraftComponent<SB, OB>;
26
+ key: K;
27
+ }): GraftComponent<z.ZodObject<Omit<SA["shape"], K> & SB["shape"]>, OA>;
28
+ /**
29
+ * Convert a GraftComponent that returns ReactElement into a real React.FC.
30
+ * This is the boundary between graft and React.
31
+ *
32
+ * Uses subscribe() internally so that reactive sources automatically
33
+ * cause re-renders. For non-reactive graphs this fires once.
34
+ *
35
+ * GraftLoading and GraftError values are rendered as null.
36
+ */
37
+ export declare function toReact<S extends z.ZodObject<z.ZodRawShape>>(gc: GraftComponent<S, ReactElement>): React.FC<z.infer<S>>;
@@ -0,0 +1,148 @@
1
+ import { useState, useEffect } from "react";
2
+ import { z } from "zod/v4";
3
+ import { isSentinel } from "./types.js";
4
+ /** Sentinel for "no previous value" in deduplication logic. */
5
+ const UNSET = Symbol("UNSET");
6
+ function isPromise(value) {
7
+ return (value !== null &&
8
+ typeof value === "object" &&
9
+ typeof value.then === "function");
10
+ }
11
+ /**
12
+ * Helper: split combined props into from's inputs and into's remaining inputs.
13
+ */
14
+ function splitProps(parsed, into, from, key) {
15
+ const fromInput = {};
16
+ for (const fromKey of Object.keys(from.schema.shape)) {
17
+ fromInput[fromKey] = parsed[fromKey];
18
+ }
19
+ const buildIntoInput = (fromOutput) => {
20
+ const intoInput = { [key]: fromOutput };
21
+ for (const intoKey of Object.keys(into.schema.shape)) {
22
+ if (intoKey === key)
23
+ continue;
24
+ if (intoKey in parsed)
25
+ intoInput[intoKey] = parsed[intoKey];
26
+ }
27
+ return intoInput;
28
+ };
29
+ return { fromInput, buildIntoInput };
30
+ }
31
+ /**
32
+ * compose({ into, from, key }):
33
+ * - into: a component with inputs SA that produces OA
34
+ * - from: a component with inputs SB that produces OB
35
+ * - key: an input name of `into` whose type matches OB
36
+ * - Result: a component whose inputs are SA minus key, plus SB,
37
+ * and whose output is OA
38
+ *
39
+ * If either `from` or `into` is async, the composed run is async.
40
+ * `from`'s output feeds into `into[key]`, remaining params bubble up.
41
+ *
42
+ * subscribe() propagates reactivity: subscribes to `from`, and whenever
43
+ * `from` emits, re-subscribes to `into` with the new value, forwarding
44
+ * `into`'s emissions to the outer callback.
45
+ *
46
+ * If `from` emits GraftLoading or GraftError, compose short-circuits —
47
+ * it passes the sentinel directly to the outer callback without calling
48
+ * `into`'s run/subscribe.
49
+ */
50
+ export function compose({ into, from, key }) {
51
+ // Build the new schema: into's shape minus key, plus from's shape
52
+ const intoShape = { ...into.schema.shape };
53
+ delete intoShape[key];
54
+ const newShape = { ...intoShape, ...from.schema.shape };
55
+ const newSchema = z.object(newShape);
56
+ const run = (props) => {
57
+ const parsed = newSchema.parse(props);
58
+ const { fromInput, buildIntoInput } = splitProps(parsed, into, from, key);
59
+ const fromOutput = from.run(fromInput);
60
+ const runInto = (resolvedFromOutput) => {
61
+ return into.run(buildIntoInput(resolvedFromOutput));
62
+ };
63
+ if (isPromise(fromOutput)) {
64
+ return fromOutput.then((v) => runInto(v));
65
+ }
66
+ return runInto(fromOutput);
67
+ };
68
+ const subscribe = (props, cb) => {
69
+ const parsed = newSchema.parse(props);
70
+ const { fromInput, buildIntoInput } = splitProps(parsed, into, from, key);
71
+ // Track the current inner (into) subscription so we can tear it down
72
+ // when from emits a new value.
73
+ let intoCleanup = null;
74
+ let disposed = false;
75
+ // Deduplication: skip re-subscription when from emits the same value.
76
+ let lastFromValue = UNSET;
77
+ const fromCleanup = from.subscribe(fromInput, (fromValue) => {
78
+ if (disposed)
79
+ return;
80
+ // Reference equality dedup — skip if same value as last time.
81
+ if (fromValue === lastFromValue)
82
+ return;
83
+ lastFromValue = fromValue;
84
+ // Tear down previous into subscription
85
+ if (intoCleanup)
86
+ intoCleanup();
87
+ intoCleanup = null;
88
+ // Short-circuit on sentinels — don't call into's run/subscribe
89
+ if (isSentinel(fromValue)) {
90
+ cb(fromValue);
91
+ return;
92
+ }
93
+ // Subscribe to into with the new from value
94
+ intoCleanup = into.subscribe(buildIntoInput(fromValue), (intoValue) => {
95
+ if (!disposed)
96
+ cb(intoValue);
97
+ });
98
+ });
99
+ return () => {
100
+ disposed = true;
101
+ fromCleanup();
102
+ if (intoCleanup)
103
+ intoCleanup();
104
+ };
105
+ };
106
+ return {
107
+ _tag: "graft-component",
108
+ schema: newSchema,
109
+ outputSchema: into.outputSchema,
110
+ run,
111
+ subscribe,
112
+ };
113
+ }
114
+ /**
115
+ * Convert a GraftComponent that returns ReactElement into a real React.FC.
116
+ * This is the boundary between graft and React.
117
+ *
118
+ * Uses subscribe() internally so that reactive sources automatically
119
+ * cause re-renders. For non-reactive graphs this fires once.
120
+ *
121
+ * GraftLoading and GraftError values are rendered as null.
122
+ */
123
+ export function toReact(gc) {
124
+ const ReactComponent = (props) => {
125
+ const [element, setElement] = useState(null);
126
+ useEffect(() => {
127
+ let cancelled = false;
128
+ const cleanup = gc.subscribe(gc.schema.parse(props), (value) => {
129
+ if (cancelled)
130
+ return;
131
+ if (isSentinel(value)) {
132
+ setElement(null);
133
+ }
134
+ else {
135
+ setElement(value);
136
+ }
137
+ });
138
+ return () => {
139
+ cancelled = true;
140
+ cleanup();
141
+ };
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [gc, ...Object.values(props)]);
144
+ return element;
145
+ };
146
+ ReactComponent.displayName = "GraftComponent";
147
+ return ReactComponent;
148
+ }
@@ -0,0 +1,7 @@
1
+ export { component } from "./component.js";
2
+ export { compose, toReact } from "./compose.js";
3
+ export { instantiate } from "./instantiate.js";
4
+ export { source } from "./source.js";
5
+ export { state } from "./state.js";
6
+ export { GraftLoading, isGraftError, View } from "./types.js";
7
+ export type { GraftComponent, GraftError, MaybePromise, Cleanup } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { component } from "./component.js";
2
+ export { compose, toReact } from "./compose.js";
3
+ export { instantiate } from "./instantiate.js";
4
+ export { source } from "./source.js";
5
+ export { state } from "./state.js";
6
+ export { GraftLoading, isGraftError, View } from "./types.js";
@@ -0,0 +1,30 @@
1
+ import { z } from "zod/v4";
2
+ import { type GraftComponent } from "./types.js";
3
+ /**
4
+ * Create an isolated instance of a subgraph template.
5
+ *
6
+ * The template is a function that builds and returns a GraftComponent.
7
+ * Each call to subscribe() invokes the template fresh, so any state()
8
+ * or source() calls inside produce independent cells/subscriptions.
9
+ *
10
+ * This is the mechanism for local state. Without instantiate(), all
11
+ * usages of a component that contains state() would share the same
12
+ * global cells. With instantiate(), each usage gets its own.
13
+ *
14
+ * The template is lazy — it is not called until subscribe() is called.
15
+ *
16
+ * To get the schema, the template is called once eagerly (at instantiate
17
+ * time) to inspect the returned component's schema. This "probe" instance
18
+ * is not used for subscriptions — each subscribe() creates a fresh one.
19
+ *
20
+ * Example:
21
+ * const TextInput = () => {
22
+ * const [Value, setValue] = state({ schema: z.string(), initial: "" });
23
+ * // ... wire into a View
24
+ * return InputView;
25
+ * };
26
+ *
27
+ * const Name = instantiate(TextInput); // isolated state
28
+ * const Email = instantiate(TextInput); // isolated state
29
+ */
30
+ export declare function instantiate<S extends z.ZodObject<z.ZodRawShape>, O>(template: () => GraftComponent<S, O>): GraftComponent<S, O>;
@@ -0,0 +1,51 @@
1
+ function isPromise(value) {
2
+ return (value !== null &&
3
+ typeof value === "object" &&
4
+ typeof value.then === "function");
5
+ }
6
+ /**
7
+ * Create an isolated instance of a subgraph template.
8
+ *
9
+ * The template is a function that builds and returns a GraftComponent.
10
+ * Each call to subscribe() invokes the template fresh, so any state()
11
+ * or source() calls inside produce independent cells/subscriptions.
12
+ *
13
+ * This is the mechanism for local state. Without instantiate(), all
14
+ * usages of a component that contains state() would share the same
15
+ * global cells. With instantiate(), each usage gets its own.
16
+ *
17
+ * The template is lazy — it is not called until subscribe() is called.
18
+ *
19
+ * To get the schema, the template is called once eagerly (at instantiate
20
+ * time) to inspect the returned component's schema. This "probe" instance
21
+ * is not used for subscriptions — each subscribe() creates a fresh one.
22
+ *
23
+ * Example:
24
+ * const TextInput = () => {
25
+ * const [Value, setValue] = state({ schema: z.string(), initial: "" });
26
+ * // ... wire into a View
27
+ * return InputView;
28
+ * };
29
+ *
30
+ * const Name = instantiate(TextInput); // isolated state
31
+ * const Email = instantiate(TextInput); // isolated state
32
+ */
33
+ export function instantiate(template) {
34
+ // Probe the template once to get schema and outputSchema.
35
+ const probe = template();
36
+ const run = (props) => {
37
+ const instance = template();
38
+ return instance.run(props);
39
+ };
40
+ const subscribe = (props, cb) => {
41
+ const instance = template();
42
+ return instance.subscribe(props, cb);
43
+ };
44
+ return {
45
+ _tag: "graft-component",
46
+ schema: probe.schema,
47
+ outputSchema: probe.outputSchema,
48
+ run,
49
+ subscribe,
50
+ };
51
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod/v4";
2
+ import { type Cleanup, type GraftComponent } from "./types.js";
3
+ /**
4
+ * Create a push-based data source with no inputs that emits values over time.
5
+ *
6
+ * This is the only way to introduce reactivity into a graft graph.
7
+ * The `run` function receives an `emit` callback; call it whenever
8
+ * you have a new value. Return a cleanup function to tear down
9
+ * subscriptions / intervals / etc.
10
+ *
11
+ * Before the first emit(), subscribers receive GraftLoading.
12
+ *
13
+ * Example:
14
+ * const Clock = source({
15
+ * output: z.number(),
16
+ * run: (emit) => {
17
+ * const id = setInterval(() => emit(Date.now()), 1000);
18
+ * return () => clearInterval(id);
19
+ * },
20
+ * });
21
+ */
22
+ export declare function source<O>({ output, run }: {
23
+ output: z.ZodType<O>;
24
+ run: (emit: (value: O) => void) => Cleanup;
25
+ }): GraftComponent<z.ZodObject<{}>, O>;
package/dist/source.js ADDED
@@ -0,0 +1,50 @@
1
+ import { z } from "zod/v4";
2
+ import { GraftLoading } from "./types.js";
3
+ /**
4
+ * Create a push-based data source with no inputs that emits values over time.
5
+ *
6
+ * This is the only way to introduce reactivity into a graft graph.
7
+ * The `run` function receives an `emit` callback; call it whenever
8
+ * you have a new value. Return a cleanup function to tear down
9
+ * subscriptions / intervals / etc.
10
+ *
11
+ * Before the first emit(), subscribers receive GraftLoading.
12
+ *
13
+ * Example:
14
+ * const Clock = source({
15
+ * output: z.number(),
16
+ * run: (emit) => {
17
+ * const id = setInterval(() => emit(Date.now()), 1000);
18
+ * return () => clearInterval(id);
19
+ * },
20
+ * });
21
+ */
22
+ export function source({ output, run }) {
23
+ const emptySchema = z.object({});
24
+ const subscribe = (_props, cb) => {
25
+ let emitted = false;
26
+ const cleanup = run((value) => {
27
+ emitted = true;
28
+ cb(value);
29
+ });
30
+ if (!emitted)
31
+ cb(GraftLoading);
32
+ return cleanup;
33
+ };
34
+ return {
35
+ _tag: "graft-component",
36
+ schema: emptySchema,
37
+ outputSchema: output,
38
+ // run() returns the first emitted value (useful for non-reactive contexts).
39
+ // For true reactivity, use subscribe().
40
+ run: (_props) => {
41
+ return new Promise((resolve) => {
42
+ const cleanup = run((value) => {
43
+ cleanup();
44
+ resolve(value);
45
+ });
46
+ });
47
+ },
48
+ subscribe,
49
+ };
50
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod/v4";
2
+ import { type GraftComponent } from "./types.js";
3
+ /**
4
+ * Create a global mutable state cell.
5
+ *
6
+ * Returns a tuple: [Component, setter].
7
+ * - Component is a source-like GraftComponent (no inputs) that emits
8
+ * the current value whenever the setter is called.
9
+ * - setter is a plain function you can call from anywhere.
10
+ *
11
+ * Example:
12
+ * const [CurrentUser, setCurrentUser] = state({
13
+ * schema: z.string(),
14
+ * initial: "anonymous",
15
+ * });
16
+ *
17
+ * setCurrentUser("alice"); // every subscriber re-runs
18
+ */
19
+ export declare function state<O>({ schema, initial }: {
20
+ schema: z.ZodType<O>;
21
+ initial: O;
22
+ }): [GraftComponent<z.ZodObject<{}>, O>, (value: O) => void];
package/dist/state.js ADDED
@@ -0,0 +1,44 @@
1
+ import { z } from "zod/v4";
2
+ /**
3
+ * Create a global mutable state cell.
4
+ *
5
+ * Returns a tuple: [Component, setter].
6
+ * - Component is a source-like GraftComponent (no inputs) that emits
7
+ * the current value whenever the setter is called.
8
+ * - setter is a plain function you can call from anywhere.
9
+ *
10
+ * Example:
11
+ * const [CurrentUser, setCurrentUser] = state({
12
+ * schema: z.string(),
13
+ * initial: "anonymous",
14
+ * });
15
+ *
16
+ * setCurrentUser("alice"); // every subscriber re-runs
17
+ */
18
+ export function state({ schema, initial }) {
19
+ let current = initial;
20
+ const listeners = new Set();
21
+ const setter = (value) => {
22
+ current = value;
23
+ for (const cb of listeners) {
24
+ cb(current);
25
+ }
26
+ };
27
+ const emptySchema = z.object({});
28
+ const subscribe = (_props, cb) => {
29
+ listeners.add(cb);
30
+ // Immediately emit the current value so subscribers don't start empty.
31
+ cb(current);
32
+ return () => {
33
+ listeners.delete(cb);
34
+ };
35
+ };
36
+ const gc = {
37
+ _tag: "graft-component",
38
+ schema: emptySchema,
39
+ outputSchema: schema,
40
+ run: () => current,
41
+ subscribe,
42
+ };
43
+ return [gc, setter];
44
+ }
@@ -0,0 +1,46 @@
1
+ import { type ReactElement } from "react";
2
+ import { z } from "zod/v4";
3
+ /** A value that may or may not be a Promise. */
4
+ export type MaybePromise<T> = T | Promise<T>;
5
+ /** Cleanup function returned by subscribe. */
6
+ export type Cleanup = () => void;
7
+ /** Sentinel value indicating a value is not yet available. */
8
+ export declare const GraftLoading: unique symbol;
9
+ /** Sentinel tag for error values propagating through the graph. */
10
+ declare const GraftErrorTag: unique symbol;
11
+ /** An error value that propagates through the graph instead of throwing. */
12
+ export type GraftError = {
13
+ readonly _tag: typeof GraftErrorTag;
14
+ readonly error: unknown;
15
+ };
16
+ /** Create a GraftError from a caught error. */
17
+ export declare const graftError: (error: unknown) => GraftError;
18
+ /** Check if a value is a GraftError. */
19
+ export declare const isGraftError: (value: unknown) => value is GraftError;
20
+ /** Check if a value is a sentinel (GraftLoading or GraftError) that should short-circuit. */
21
+ export declare const isSentinel: (value: unknown) => value is typeof GraftLoading | GraftError;
22
+ /**
23
+ * A graft component: a typed function from inputs (schema S) to output O.
24
+ *
25
+ * When O is ReactElement, this is a visual component.
26
+ * When O is something else (number, string, object...), this is a data source.
27
+ * compose() doesn't care — it just wires outputs into inputs.
28
+ * toReact() requires O to be ReactElement.
29
+ *
30
+ * The run function may return O or Promise<O>. When any component in a
31
+ * composed graph is async, the entire graph becomes async.
32
+ *
33
+ * subscribe() is the reactive primitive: it calls the callback whenever
34
+ * the output changes. For regular components this fires once. For graphs
35
+ * containing sources, it fires whenever a source emits.
36
+ */
37
+ export interface GraftComponent<S extends z.ZodObject<z.ZodRawShape>, O> {
38
+ readonly _tag: "graft-component";
39
+ readonly schema: S;
40
+ readonly outputSchema: z.ZodType<O>;
41
+ readonly run: (props: z.infer<S>) => MaybePromise<O>;
42
+ readonly subscribe: (props: z.infer<S>, cb: (value: O | typeof GraftLoading | GraftError) => void) => Cleanup;
43
+ }
44
+ /** Output schema for components that return JSX. */
45
+ export declare const View: z.ZodType<ReactElement>;
46
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,13 @@
1
+ import { z } from "zod/v4";
2
+ /** Sentinel value indicating a value is not yet available. */
3
+ export const GraftLoading = Symbol("GraftLoading");
4
+ /** Sentinel tag for error values propagating through the graph. */
5
+ const GraftErrorTag = Symbol("GraftError");
6
+ /** Create a GraftError from a caught error. */
7
+ export const graftError = (error) => ({ _tag: GraftErrorTag, error });
8
+ /** Check if a value is a GraftError. */
9
+ export const isGraftError = (value) => value !== null && typeof value === "object" && "_tag" in value && value._tag === GraftErrorTag;
10
+ /** Check if a value is a sentinel (GraftLoading or GraftError) that should short-circuit. */
11
+ export const isSentinel = (value) => value === GraftLoading || isGraftError(value);
12
+ /** Output schema for components that return JSX. */
13
+ export const View = z.custom(() => true);
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "graftjs",
3
+ "version": "0.1.0",
4
+ "description": "Compose React components by wiring named parameters — type-safe, no prop drilling, no hooks",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "npx tsx --test tests/*.test.tsx",
20
+ "prepublishOnly": "tsc"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/uriva/graft.git"
25
+ },
26
+ "homepage": "https://github.com/uriva/graft",
27
+ "keywords": [
28
+ "react",
29
+ "compose",
30
+ "component",
31
+ "graph",
32
+ "wiring",
33
+ "zod",
34
+ "typed",
35
+ "reactive"
36
+ ],
37
+ "peerDependencies": {
38
+ "react": ">=18"
39
+ },
40
+ "devDependencies": {
41
+ "@testing-library/react": "^14.0.0",
42
+ "@types/react": "^18.2.0",
43
+ "@types/react-dom": "^18.2.0",
44
+ "@vitejs/plugin-react": "^5.1.4",
45
+ "global-jsdom": "^28.0.0",
46
+ "jsdom": "^28.1.0",
47
+ "react": "^18.2.0",
48
+ "react-dom": "^18.2.0",
49
+ "tsx": "^4.0.0",
50
+ "typescript": "^5.4.0",
51
+ "vite": "^7.3.1"
52
+ },
53
+ "license": "MIT",
54
+ "dependencies": {
55
+ "zod": "^3.25.76"
56
+ }
57
+ }