create Graft
Takes a hook and wraps it in a context: a Provider that mounts it, and use() to read it. Everything else is the hook you wrote.
API
Options
| prop | type | default | |
|---|---|---|---|
graft | (options) => TValue | — | the hook the provider mounts |
inject? | (() => void)[] | [] | hooks run inside the provider |
name? | string | "Graft" | shown in DevTools and in the error |
Usage
A hook, a context
The hook is ordinary: a store, an operation built on it, and an object returned. What createGraft adds is the distribution — a Provider that mounts it and a reader that finds it — so the file below is the entire feature, and nothing about it is written anywhere else.
// -constants/graft_likes.ts
export const GraftLikes = createGraft({
name: "GraftLikes",
graft: useGraft,
inject: [useInject],
});
function useGraft({ start = 0 }: { start?: number }) {
const store = useGraftStore<number>({ defaultValue: start });
const like = useCallback(() => store.setValue((value) => value + 1), [store]);
return { ...store, like };
}Options travel as props, typed from the hook's own signature. Two providers on the same page are two unrelated instances, which is why start can differ and neither knows about the other.
// -components/likes_panel.tsx
export const LikesPanel = () => (
<GraftLikes.Provider start={10}>
<LikeButton />
</GraftLikes.Provider>
);
// -components/like_button.tsx
export const LikeButton = () => {
const { like, value } = GraftLikes.use();
return <Button onClick={like}>{`likes: ${value}`}</Button>;
};A rule to be injected to the graft
Say that reaching 5 should ask whether to start over. That is not a rule about a button — the next component that increments would have to remember it too — and it is not something a component can even answer alone, since it has to wait for a person. It belongs to the feature, so it goes in inject.
// -constants/graft_likes.ts
export const GraftLikesLimit = createGraft({ name: "GraftLikesLimit", graft: useGraftAlert<void> });
export const GraftLikes = createGraft({
name: "GraftLikes",
graft: useGraft,
inject: [useInject],
});
function useInject() {
const { resetValue, value } = GraftLikes.use();
const { open } = GraftLikesLimit.use();
useEffect(() => {
if (value < LIKES_LIMIT) return;
let cancelled = false;
void open().then((confirmed) => {
if (cancelled || !confirmed) return;
resetValue();
});
return () => {
cancelled = true;
};
}, [open, resetValue, value]);
}
// -components/likes_demo.tsx
export const LikesDemo = ({ start }: { start: number }) => (
<GraftLikesLimit.Provider>
<GraftLikes.Provider start={start}>
<LikeButton />
<LimitLayer />
</GraftLikes.Provider>
</GraftLikesLimit.Provider>
);The injected hook reads its own graft and the alert above it, waits for the answer, and acts on it. No component takes part: whoever increments simply increments, and the question appears.
Both are the same graft and behave separately: each provider has its own count, its own alert and its own injected hook, so one asking does not disturb the other, and starting over lands on the start that provider was given.
Details
The Provider mounts the hook
The provider calls your hook and puts what it returns into the context. Whatever the hook accepts becomes a prop, typed from its signature — no options object to declare, no defaults to repeat. Each provider is one instance, so two of them are two independent states, and the value is rebuilt on every render, which is what keeps a hook holding a ref honest.
Rebuilding it means the context value is a new object on every render, so every reader of that graft wakes up when the provider does. That is the cost of the shape, and the reason grafts are meant to be narrow: a component that only needs to open something should be reading a graft that only knows how to open it.
use() reads it
use() returns the value, or throws naming the graft if it is called outside its provider — a mistake you find at the first render rather than through a null three components later. Being a call on a use name, it is also visible to react-hooks/rules-of-hooks, which an anonymous hook read from a context would not be.
The context itself stays private, so the provider is the only way in: there is no escape hatch that hands a reader a value the hook never produced. name is what labels both the context and the provider in DevTools.
Any hook, including yours
graft takes a hook. Not a hook of ours — any hook. The ones this library ships are the patterns that came up often enough to be worth writing once; a graft built out of useState and useDeferredValue, or out of a query client, is the same thing and gets the same provider, the same reader and the same types.
// -constants/graft_filters.ts
export const GraftFilters = createGraft({
name: "GraftFilters",
graft: useGraft,
});
function useGraft() {
const [query, setQuery] = useState("");
const deferred = useDeferredValue(query);
return { query, deferred, setQuery };
}Extend, override, or both
There is no extension point because none is needed. Call the hooks you want inside your own, spread what you took, add what is missing, and replace what does not fit — including behaviour: a close that asks for confirmation first is just a close of your own, and the callers below never learn that it changed.
// -constants/graft_dialog.ts
export const GraftDialogInvoice = createGraft({ name: "GraftDialogInvoice", graft: useGraft });
function useGraft() {
const dialog = useGraftDialog<Invoice>();
const discard = GraftAlertDiscard.use();
const close = useCallback(async () => {
if (!(await discard.open())) return;
dialog.close();
}, [dialog, discard]);
return { ...dialog, close };
}The type comes from the object you return, so an overridden member carries its new signature and the old one is simply gone — no Omit, no interface to keep in step, and no way for the declaration to drift from the implementation. Compose four hooks into one, expose three members out of twenty, rename them: what a graft is worth is decided in your file, not in ours.
The same goes upward: a graft reads grafts above it exactly like any component does, and which one sits above which is your call, written as ordinary nesting. The library does not arrange providers and has no notion of a graph.
What inject is for
A feature is rarely only state and operations. Something has to keep the url in step, report to analytics, listen for a shortcut, warn before leaving — effects that concern the feature and no particular component. Without a home they end up in whichever component happened to be mounted, and from then on that component cannot be removed without breaking something unrelated to it.
// -constants/graft_dialog.ts
export const GraftDialogInvoice = createGraft({
name: "GraftDialogInvoice",
graft: useGraft,
inject: [useInject],
});
function useInject() {
const { isActive, storage } = GraftDialogInvoice.use();
const { invoice } = useSearch({ strict: false });
const navigate = useNavigate();
useEffect(() => {
const target = isActive ? storage?.id : undefined;
if (invoice === target) return;
void navigate({ to: "/invoices", search: { invoice: target }, replace: true });
}, [invoice, isActive, navigate, storage?.id]);
}In inject they belong to the provider instead. They run wherever it is mounted, for as long as it is mounted, and every consumer gets them without asking — the alternative being the version below, where the knowledge has leaked into a screen that has no reason to hold it.
// the same effect, written in a component instead
export const InvoiceScreen = () => {
const { isActive, storage } = GraftDialogInvoice.use();
useInvoiceUrl(isActive, storage); // every consumer must remember this
useInvoiceTelemetry(isActive); // and this
useInvoiceShortcuts(); // and this
return <InvoiceList />;
};Hooks, not components
inject takes hooks and wraps each one in a component of its own that renders null. Two consequences follow. A state update inside one does not re-render the others, or the children — the isolation is real, not a convention. And the graft file never has to import React or contain a line of JSX, which is what keeps -constants free of UI.
They run inside the provider, so use() works and every graft above is available — routing, queries, anything. Being use functions, they are also linted like the hooks they are.
Siblings, never wrappers
Injected hooks sit beside children, not around them. It matters the moment children stop rendering: a modal that closes, a branch behind a condition, a list that empties. The effects keep running, because they belong to the provider and not to the tree below it.
Which is the reason a graft can be the thing that opens something. The url sync above survives the dialog it controls being closed — otherwise it would have no way to reopen it on a deep link, having unmounted with it.