New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

graftjs

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

graftjs

Compose React components by wiring named parameters — type-safe, no prop drilling, no hooks

latest
Source
npmnpm
Version
0.5.0
Version published
Maintainers
1
Created
Source
                                 ▄▄▄▄            
                                 ██▀▀▀     ██     
  ▄███▄██   ██▄████   ▄█████▄  ███████   ███████  
 ██▀  ▀██   ██▀       ▀ ▄▄▄██    ██        ██     
 ██    ██   ██       ▄██▀▀▀██    ██        ██     
 ▀██▄▄███   ██       ██▄▄▄███    ██        ██▄▄▄  
  ▄▀▀▀ ██   ▀▀        ▀▀▀▀ ▀▀    ▀▀         ▀▀▀▀  
  ▀████▀▀ 

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 graftjs

Why

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, Alice

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

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
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. ~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 graftjs

Requires React 18+ as a peer dependency. Uses zod v4 (zod/v4 import) for schemas.

License

MIT

Keywords

react

FAQs

Package last updated on 18 Feb 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts