graftjs
v0.5.0
Published
Compose React components by wiring named parameters — type-safe, no prop drilling, no hooks
Maintainers
Readme
▄▄▄▄
██▀▀▀ ██
▄███▄██ ██▄████ ▄█████▄ ███████ ███████
██▀ ▀██ ██▀ ▀ ▄▄▄██ ██ ██
██ ██ ██ ▄██▀▀▀██ ██ ██
▀██▄▄███ ██ ██▄▄▄███ ██ ██▄▄▄
▄▀▀▀ ██ ▀▀ ▀▀▀▀ ▀▀ ▀▀ ▀▀▀▀
▀████▀▀ The smallest API imaginable.
graft
Compose React components by wiring outputs into inputs.
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.
No prop drilling. No Context. No useState. No useEffect. No manual subscriptions.
npm install graftjsWhy
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.
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 applied to React.
Have you ever chased a stale closure bug through a useEffect dependency array? Or watched a parent re-render cascade through child components that didn't even use the state that changed? Or needed to add a parameter deep in a component tree and had to refactor every intermediate component just to thread it through?
Graft eliminates all three by design.
Core concepts
A component is a typed function
It takes named inputs and produces an output. That's it.
import { z } from "zod/v4";
import { component, View } from "graftjs";
const Greeting = component({
input: z.object({ name: z.string() }),
output: View,
run: ({ name }) => <h1>Hello, {name}</h1>,
});The output doesn't have to be JSX. A component that returns data is just a transform:
const FormatPrice = component({
input: z.object({ price: z.number() }),
output: z.string(),
run: ({ price }) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price),
});compose wires components together
compose({ into, from, key }) feeds from's output into into's input named key. The satisfied input disappears. Unsatisfied inputs bubble up as the new component's props.
import { compose } from "graftjs";
const PriceDisplay = component({
input: z.object({ displayPrice: z.string() }),
output: View,
run: ({ displayPrice }) => <span>{displayPrice}</span>,
});
// FormatPrice needs { price }, PriceDisplay needs { displayPrice }
// After compose: the result needs only { price }
const FormattedPrice = compose({
into: PriceDisplay,
from: FormatPrice,
key: "displayPrice",
});Wire multiple inputs at once:
const Card = component({
input: z.object({ title: z.string(), body: z.string() }),
output: View,
run: ({ title, body }) => <div><h2>{title}</h2><p>{body}</p></div>,
});
const App = compose({
into: Card,
from: { title: TitleSource, body: BodySource },
});toReact converts to a regular React component
When all inputs are satisfied (or you want the remaining ones as props), toReact gives you a standard React.FC.
import { toReact } from "graftjs";
const App = toReact(FormattedPrice);
// TypeScript knows this needs { price: number }
<App price={42000} />emitter replaces useEffect
In React you'd use useEffect + useState for a WebSocket, a timer, or a browser API. In graft, that's an emitter — a component that pushes values over time. Everything downstream re-runs automatically.
import { emitter } from "graftjs";
const PriceFeed = emitter({
output: z.number(),
run: (emit) => {
const ws = new WebSocket("wss://stream.binance.com:9443/ws/btcusdt@trade");
ws.onmessage = (e) => emit(Number(JSON.parse(e.data).p));
return () => ws.close(); // cleanup on unmount
},
});Wire it into the graph and you have a live-updating UI with no hooks:
const LivePrice = compose({ into: FormatPrice, from: PriceFeed, key: "price" });
const App = toReact(compose({ into: PriceDisplay, from: LivePrice, key: "displayPrice" }));
// No props needed — everything is wired internally
<App />state replaces useState
Returns a [Component, setter] tuple. The component emits the current value. The setter can be called from anywhere — event handlers, callbacks, outside the graph.
import { state } from "graftjs";
const [CurrentUser, setCurrentUser] = state({
schema: z.string(),
initial: "anonymous",
});
const App = toReact(
compose({ into: Greeting, from: CurrentUser, key: "name" }),
);
// Renders: Hello, anonymous
setCurrentUser("Alice");
// Re-renders: Hello, Aliceinstantiate creates isolated copies
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.
Graft defaults to "the". state() creates the cell. emitter() creates the stream. There is exactly one, and every subscriber sees the same value. Definiteness is the default.
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 emitter subscriptions.
import { instantiate } from "graftjs";
const TextField = () => {
const [Value, setValue] = state({ schema: z.string(), initial: "" });
const Input = component({
input: z.object({ label: z.string(), text: z.string() }),
output: View,
run: ({ label, text }) => (
<label>
{label}
<input value={text} onChange={(e) => setValue(e.target.value)} />
</label>
),
});
return compose({ into: Input, from: Value, key: "text" });
};
// Two independent text fields — typing in one doesn't affect the other
const NameField = instantiate(TextField);
const EmailField = instantiate(TextField);Full example
A live crypto price card. Price streams over WebSocket, coin name is fetched async, both feed into a card layout.
graph BT
App["<App coinId="bitcoin" />"] -- coinId --> CoinName
CoinName -- name --> Header
Header -- header --> PriceCard
PriceFeed -- price --> FormatPrice
FormatPrice -- displayPrice --> PriceCard
PriceCard -- View --> Output((" "))
style Output fill:none,stroke:noneimport { z } from "zod/v4";
import { component, compose, emitter, toReact, View } from "graftjs";
const PriceFeed = emitter({
output: z.number(),
run: (emit) => {
const ws = new WebSocket("wss://stream.binance.com:9443/ws/btcusdt@trade");
ws.onmessage = (e) => emit(Number(JSON.parse(e.data).p));
return () => ws.close();
},
});
const CoinName = component({
input: z.object({ coinId: z.string() }),
output: z.string(),
run: async ({ coinId }) => {
const res = await fetch(
`https://api.coingecko.com/api/v3/coins/${coinId}?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false`,
);
return (await res.json()).name;
},
});
const FormatPrice = component({
input: z.object({ price: z.number() }),
output: z.string(),
run: ({ price }) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(price),
});
const Header = component({
input: z.object({ name: z.string() }),
output: View,
run: ({ name }) => <h1>{name}</h1>,
});
const PriceCard = component({
input: z.object({ header: View, displayPrice: z.string() }),
output: View,
run: ({ header, displayPrice }) => (
<div>
{header}
<span>{displayPrice}</span>
</div>
),
});
const LivePrice = compose({ into: FormatPrice, from: PriceFeed, key: "price" });
const NamedHeader = compose({ into: Header, from: CoinName, key: "name" });
const App = toReact(
compose({ into: PriceCard, from: { displayPrice: LivePrice, header: NamedHeader } }),
);
// One prop left — everything else is wired internally
<App coinId="bitcoin" />What you get
- No dependency arrays. There are no hooks, so there are no stale closures and no rules-of-hooks footguns.
- No unnecessary re-renders. Value changes only propagate along explicit
compose()edges. If emitter A feeds component X and emitter B feeds component Y, A changing has zero effect on Y. This isn't an optimization — graft simply doesn't have a mechanism to cascade re-renders. - No prop drilling. Need to wire a data source into a deeply nested component? Just
compose()it directly. No touching the components in between. - Runtime type safety. Every
composeboundary validates with zod. A type mismatch gives you a clearZodErrorat the boundary where it happened — not a silentundefinedpropagating through your tree. - Async just works. Make
runasync and loading states propagate automatically. Errors short-circuit downstream. NouseEffect, noisLoadingboilerplate. - Every piece is independently testable. Components are just functions — call
run()directly with plain objects, no render harness needed.
The idea comes from graph programming. Graft drastically reduces the tokens needed to construct something, and drastically reduces the number of possible bugs. It's a runtime library, not a compiler plugin. ~400 lines of code, zero dependencies beyond React and zod.
Loading and error states
When a component is async, graft handles the in-between time with two sentinels that flow through the graph like regular data.
GraftLoading — emitted when a value isn't available yet. Async components emit it immediately, then the resolved value. Emitters emit it until their first emit() call. compose short-circuits on loading — downstream run functions aren't called. toReact renders null.
GraftError — wraps a caught error from an async rejection. Like loading, it short-circuits through compose. toReact renders null.
import { GraftLoading, isGraftError } from "graftjs";
const AsyncData = component({
input: z.object({ id: z.string() }),
output: z.number(),
run: async ({ id }) => {
const res = await fetch(`/api/data/${id}`);
if (!res.ok) throw new Error("fetch failed");
return (await res.json()).value;
},
});
// subscribe() lets you observe the full lifecycle:
AsyncData.subscribe({ id: "123" }, (value) => {
if (value === GraftLoading) {
// Still loading...
} else if (isGraftError(value)) {
console.error("Error:", value.error);
} else {
console.log("Got value:", value);
}
});state() never produces sentinels — it always has a value (the initial value).
Deduplication
compose deduplicates emissions from from using reference equality (===). If from emits the same value twice in a row, into's run isn't called and nothing propagates downstream. This means an emitter spamming the same primitive is a no-op, and calling a state setter with the current value is free.
Install
npm install graftjsRequires React 18+ as a peer dependency. Uses zod v4 (zod/v4 import) for schemas.
License
MIT
