▄▄▄▄
██▀▀▀ ██
▄███▄██ ██▄████ ▄█████▄ ███████ ███████
██▀ ▀██ ██▀ ▀ ▄▄▄██ ██ ██
██ ██ ██ ▄██▀▀▀██ ██ ██
▀██▄▄███ ██ ██▄▄▄███ ██ ██▄▄▄
▄▀▀▀ ██ ▀▀ ▀▀▀▀ ▀▀ ▀▀ ▀▀▀▀
▀████▀▀
graft /ɡrɑːft/ — a horticultural technique that joins tissues from two plants so they grow together as one.
The smallest API imaginable.
Graft is a tiny self sufficient TypeScript programming framework.
- Focuses on non-linear, async friendly composition.
- Can be used for data pipelines or to replace React entirely.
- Compatible with existing React apps via
toReact.
The main concepts:
component - runtime typed functions (which can be async).
compose - a way to combine components, ending up with a component again.
For example,
compose({ into: target, from: { keyA: sourceA, keyB: sourceB } });connects between 3 components. The remaining unsatisfied inputs of target,
sourceA and sourceB bubble up as the composed component's new required
inputs. The result is (again) a component with a set of known typed inputs and
output.
Turns out just this lets you do most things.
No prop drilling. No Context. No useState. No useEffect. No manual subscriptions.
To install it -
npm install graftjsGraft solves problems that arise in UI (e.g. in React) and non-UI programming.
React is great but has two main issues:
- You have to sprinkle hooks everywhere, ending up with impure components or
things like
useEffectdependencies array, or weird hook rules. - You nest components within each other, coupling views which leads to prop drilling / signature duplication.
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.
You describe the wiring directly, focusing on types and composition of independent elements, which allows you to keep them decoupled and pure.
You might wonder if it's like Haskell or Elm. It's actually quite different. It improves on linear composition and nesting which are the common patterns in functional languages. You can compose in multiple directions, still ending up with simple components, as unsatisfied inputs become the new component's props. This is graph programming applied to TypeScript and UI.
In programming, everything is a tradeoff. Graft pays for these advantages with the following choices:
- Runtime schemas require a library like
zod, less neat than just using TypeScript types. But without it, it would be very easy to miss a dependency. If Graft would ever become a programming language one day, this could be greatly improved. - Most of the computation graph is eager, so if you have a ternary downstream, you might still compute all values for the two branches. In most cases this shouldn't be a problem, as the CPU intensive work is rendering and not pure computations. Secondly, in the future can be solved with lazy evaluation.
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({ 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",
});You can also 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 },
});When into and from share a remaining input with the same key name, graft
merges them into a single prop. The caller provides it once and the value is
routed to both sides.
const Header = component({
input: z.object({ userId: z.string() }),
output: View,
run: ({ userId }) => <h1>User {userId}</h1>,
});
const Body = component({
input: z.object({ userId: z.string() }),
output: z.string(),
run: ({ userId }) => `Profile for ${userId}`,
});
const Layout = component({
input: z.object({ header: View, body: z.string(), userId: z.string() }),
output: View,
run: ({ header, body, userId }) => (
<div data-user={userId}>{header}<p>{body}</p></div>
),
});
const Page = compose({
into: Layout,
from: { header: Header, body: Body },
});
// Page needs only { userId: string } — one prop feeds all three components
<Page userId="alice" />;This works because all three schemas define userId as z.string(). The
value is provided once and passed to Header, Body, and Layout.
If the overlapping key has incompatible types in into and from, graft
throws at compose time:
const A = component({
input: z.object({ x: z.number() }),
output: z.string(),
run: ({ x }) => String(x),
});
const B = component({
input: z.object({ x: z.string(), val: z.string() }),
output: z.string(),
run: ({ x, val }) => `${x}:${val}`,
});
// Throws: overlapping input key "x" has incompatible types
compose({ into: B, from: A, key: "val" });Rename one of the keys to disambiguate.
### toReact converts to a regular React component
Existing react apps can adopt gradually - `toReact` gives you a standard
`React.FC`.
```tsx
import { toReact } from "graftjs";
const App = toReact(FormattedPrice);
// TypeScript knows this needs { price: number }
<App price={42000} />;
Have a React component you want to use in a graft graph? fromReact wraps it:
import { fromReact } from "graftjs";
import DatePicker from "react-datepicker";
const GraftDatePicker = fromReact(
DatePicker,
z.object({ selected: z.date(), onChange: z.function() }),
);
// Now compose it like any other graft component
const App = compose({ into: GraftDatePicker, from: SelectedDate, key: "selected" });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 already wired
<App />;Like useState, but it lives in the graph — not inside a component's render
cycle. Returns a [Component, setter] tuple. The component emits the current
value to any subscriber. The setter can be called from anywhere.
import { component, compose, state } from "graftjs";
import { z } from "zod/v4";
const [Count, setCount] = state({ schema: z.number(), initial: 0 });
const Doubled = component({
input: z.object({ n: z.number() }),
output: z.number(),
run: ({ n }) => n * 2,
});
const DoubledCount = compose({ into: Doubled, from: Count, key: "n" });
DoubledCount.subscribe({}, (value) => {
console.log(value); // 0, then 2, then 4
});
setCount(1);
setCount(2);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);Some computations need their previous result — accumulators, reducers, running
totals. Instead of adding a new node type, graft expresses this as a feedback
edge in the graph. Pass future: true to compose:
compose({ into, key, future: true, initial }) connects into's output back
to its own input named key, delayed by one step. The first invocation uses
initial. Each subsequent invocation uses the previous output. The key is
removed from the composed component's schema — it's internally satisfied.
The component stays a pure function. The statefulness lives in the graph topology.
import { z } from "zod/v4";
import { component, compose } from "graftjs";
const Adder = component({
input: z.object({ event: z.number(), acc: z.number() }),
output: z.number(),
run: ({ event, acc }) => acc + event,
});
// Output feeds back to "acc", starting at 0
const RunningTotal = compose({ into: Adder, key: "acc", future: true, initial: 0 });
// RunningTotal input: { event: z.number() }, output: z.number()
RunningTotal.run({ event: 5 }); // 5
RunningTotal.run({ event: 3 }); // 8
RunningTotal.run({ event: 2 }); // 10It composes with emitters the same way everything else does:
import { z } from "zod/v4";
import { component, compose, emitter } from "graftjs";
const ClickStream = emitter({
output: z.number(),
run: (emit) => {
document.addEventListener("click", () => emit(1));
return () => {};
},
});
const ClickCounter = compose({
into: component({
input: z.object({ click: z.number(), total: z.number() }),
output: z.number(),
run: ({ click, total }) => total + click,
}),
key: "total",
future: true,
initial: 0,
});
const LiveCount = compose({
into: ClickCounter,
from: ClickStream,
key: "click",
});
// LiveCount has no inputs — fully wired
LiveCount.subscribe({}, (count) => console.log("clicks:", count));initial is validated against the component's output schema at construction
time. If it doesn't match, you get a ZodError immediately.
Use instantiate with a future edge to get independent accumulators per
subscription — each instance starts fresh from initial.
A change in the feedback value alone never triggers a re-run. The feedback value is read passively at the start of each invocation — it doesn't act as a subscription source. Only upstream changes (new props or emitter emissions) cause re-runs. This means infinite loops are impossible by construction. The output of one run updates the accumulator, but that update is inert until the next external trigger arrives.
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:none
import { 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" />;- 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. Tiny with zero dependencies except zod as the types manager.
When a component is async or backed by an emitter, graft uses two sentinels that flow through the graph like regular data:
GraftLoading — the value isn't available yet. Async components emit it
immediately, then the resolved value. Emitters emit it until their first
emit() call.
GraftError — wraps a caught error from an async rejection.
By default, both sentinels short-circuit through compose — downstream run
functions aren't called, and toReact renders null. This is the right default
for most of the graph: intermediate transforms shouldn't need to care about
loading states.
When a component does want to handle these states — show a spinner, display an
error message — use the status option:
import {
component,
compose,
emitter,
isGraftError,
isGraftLoading,
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 PriceDisplay = component({
input: z.object({ price: z.number() }),
output: View,
status: ["price"],
run: ({ price }) => {
if (isGraftLoading(price)) return <div>Loading...</div>;
if (isGraftError(price)) return <div>Error: {String(price.error)}</div>;
return <div>${price}</div>;
},
});
const App = compose({ into: PriceDisplay, from: PriceFeed, key: "price" });status: ["price"] tells graft that PriceDisplay wants to receive loading and
error sentinels on its price input instead of having compose short-circuit.
Keys not listed in status still short-circuit as usual.
You can list multiple keys: status: ["price", "name"].
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.
If you want individual events counted (e.g. button clicks, incoming messages),
model them as distinct values — for instance by attaching a timestamp or
incrementing counter. Reference equality means two separate { type: "click" }
objects will propagate (different references), but two emissions of the same
primitive 1 in a row won't.
The graph is currently eager — once a component is subscribed to, everything
upstream runs, even if the run function only uses some of its inputs
conditionally. With toReact, React's mount/unmount lifecycle handles this
naturally: unmounted components have no active subscriptions, so their upstream
branches don't run. But the graph itself doesn't know which inputs a run will
actually read.
A potential future direction is lazy evaluation: instead of receiving a resolved
object, run would receive a get function —
run: async (get) => { const price = await get("price"); ... }. Branches would
only activate when get is called, and the set of active subscriptions would be
tracked and updated per invocation. This is similar to how MobX and Vue track
dependencies at runtime. It would make the graph shape dynamic rather than
static, which is a meaningful complexity increase, so it's not currently planned
— but it's a natural extension if demand for conditional upstream computation
arises.
