React Graft
Turns a hook into a context. A graft is one piece of state, its operations and its side effects, written in one file and read anywhere below its provider.
Installation
npm install react-graftThe shape
export const GraftUserDialog = createGraft({
name: "GraftUserDialog",
graft: useGraftDialog<User>,
});
<GraftUserDialog.Provider>
<Toolbar />
</GraftUserDialog.Provider>
const dialog = GraftUserDialog.use();Why
Small hooks, plain React
A graft is one file holding one concern: its state, its operations, its side effects — a dialog, a draft, a tab, an alert. Small on purpose and many, not one object per screen, because granularity is what decides how much of the tree wakes up when something changes.
And it is React all the way down. No proxies, no compilers, no subscription trick underneath: a hook you wrote, called by a provider, read from a context. The rules of hooks apply and the linter enforces them, the DevTools show what you named, and extending is spreading — take a graft, override a member, and the type follows the object you returned.
Atomic, never global
A graft lives in the tree and nowhere else. Two providers are two independent instances, and a feature mounted twice on the same page keeps two separate states without a key, a namespace or an id to invent. Unmount it and it is gone, with its effects.
Which makes scope a placement decision rather than a configuration one: expressed the way React already expresses it, by where the provider goes. A graft can be as narrow as a single panel or as wide as the whole app, and moving it from one to the other is moving a line of JSX.
Boundaries
It does not leave React
Everything here is a hook called from a component, so the rules of hooks apply and the linter enforces them — use() included, because it is a call on a name the plugin recognises. Providers nest the way any provider nests, and the order is yours to write: a graft that reads another simply has to sit below it. Nothing is arranged behind your back.
The reverse is also true. Grafts are subject to how React propagates context, so a value that changes wakes every reader of that graft. There is no selector here, and that is a deliberate omission — the fix is usually a smaller graft, not a cleverer subscription.
Logic only
No hook in this library renders anything, touches the DOM, or reaches for a platform API — no localStorage, no window, no element. Persisting a value is something you add on top. These are client hooks and they hold state, so a Server Component is not where they live; what they do give you is a server pass that renders like the browser one, with nothing to guard and nothing to mismatch on hydration — and the same code running unchanged outside the browser altogether. What a dialog looks like, and when its content leaves the screen, is your UI kit's problem, not this library's.
inject keeps that line intact. It takes hooks, not components: side effects that belong to a graft — syncing the url, logging, listening — run inside its provider without a wrapper component and without a single line of JSX in the file.
What it is not
It holds state — that is the whole job, and calling it a state manager would not be wrong. What it is not is a store: nothing here survives the tree that created it, and there is no singleton, no module-level value, nothing to hydrate or reset between tests. State lives in useState and useRef, and React remains the only thing scheduling renders.
Past that, the list is short. Not a UI kit: it draws nothing. Not a framework: no router, no data layer, no plugin system. A factory and a handful of hooks, with zero dependencies beyond React.