Skip to content

uriva/graft

Repository files navigation

                                ▄▄▄▄            
                                ██▀▀▀     ██     
 ▄███▄██   ██▄████   ▄█████▄  ███████   ███████  
██▀  ▀██   ██▀       ▀ ▄▄▄██    ██        ██     
██    ██   ██       ▄██▀▀▀██    ██        ██     
▀██▄▄███   ██       ██▄▄▄███    ██        ██▄▄▄  
 ▄▀▀▀ ██   ▀▀        ▀▀▀▀ ▀▀    ▀▀         ▀▀▀▀  
 ▀████▀▀

graft /ɡrɑːft/ — a horticultural technique that joins tissues from two plants so they grow together as one.

The smallest API imaginable.

graft

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 graftjs

GitHub | npm

Why

Graft solves problems that arise in UI (e.g. in React) and non-UI programming.

React is great but has two main issues:

  1. You have to sprinkle hooks everywhere, ending up with impure components or things like useEffect dependencies array, or weird hook rules.
  2. 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.

The tradeoffs

In programming, everything is a tradeoff. Graft pays for these advantages with the following choices:

  1. 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.
  2. 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.

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",
});

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 },
});

Overlapping input keys

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} />;

fromReact wraps existing React components

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" });

emitters

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 />;

state is mutable state, not tied to a component

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);

instantiate 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);

Future edges feed output back as input

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 }); // 10

It 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.

No loops by construction

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.

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["&lt;App coinId=&quot;bitcoin&quot; /&gt;"] -- coinId --> CoinName
    CoinName -- name --> Header
    Header -- header --> PriceCard
    PriceFeed -- price --> FormatPrice
    FormatPrice -- displayPrice --> PriceCard
    PriceCard -- View --> Output((" "))

    style Output fill:none,stroke:none
Loading
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" />;

Live crypto price card demo

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 compose boundary validates with zod. A type mismatch gives you a clear ZodError at the boundary where it happened — not a silent undefined propagating through your tree.
  • Async just works. Make run async and loading states propagate automatically. Errors short-circuit downstream. No useEffect, no isLoading boilerplate.
  • 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.

Loading and error states

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.

Handling loading and errors explicitly

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"].

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.

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.

Eagerness and future laziness

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.

About

A simpler way to program UI and in general.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors