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? | ((value: TValue) => void)[] | [] | hooks run inside the provider |
name? | string | "Graft" | shown in DevTools and in the error |
Returns
| prop | type | default | |
|---|---|---|---|
Provider | (props: TOptions) => ReactNode | — | mounts the hook |
use | () => TValue | — | reads it, throws outside the provider |
displayName | string | — | the name it was given |
Usage
A hook, a context
The hook is ordinary React: some state, the operations 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.
// -constants/graft_likes.ts
export const GraftLikes = createGraft({
name: "GraftLikes",
graft: useGraft,
});
function useGraft({ start = 0 }: { start?: number }) {
const [value, setValue] = useState(start);
const like = useCallback(() => setValue((count) => count + 1), []);
const reset = useCallback(() => setValue(start), [start]);
return { value, like, reset };
}Options travel as props, typed from the signature of the hook. 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 onClickBlur={like}>
<Button.Label label={`likes: ${value}`} />
</Button>
);
};A rule injected into 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: useConfirm });
export const GraftLikes = createGraft({
name: "GraftLikes",
graft: useGraft,
inject: [useInjectLimit],
});
function useInjectLimit({ reset, value }: { reset: () => void; value: number }) {
const { open } = GraftLikesLimit.use();
useEffect(() => {
if (value < LIKES_LIMIT) return;
let cancelled = false;
void open().then((confirmed) => {
if (cancelled || !confirmed) return;
reset();
});
return () => {
cancelled = true;
};
}, [open, reset, 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 takes the value of its own graft as its argument and reads the confirmation graft above it with use(), 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 confirmation 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. The value is rebuilt on every render, which keeps a hook holding a ref honest and means every reader of that graft wakes 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. The context itself stays private, so there is no escape hatch that hands a reader a value the hook never produced. 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.
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: [useInjectUrl],
});
function useInjectUrl({ isActive, storage }: { isActive: boolean; storage: Invoice | null }) {
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 />;
};How inject runs
inject takes hooks and wraps each one in a component of its own that renders null, receiving the value the graft produced — the same object use() returns, so an injected hook never has to name the constant that mounts it. A state update inside one does not re-render the others, or the children. They run inside the provider, so every graft above is still available through use(), and the graft file never has to import React or contain a line of JSX.
They sit beside children and after them, never 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 unmounted with it and had no way to reopen it on a deep link.
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 = useDialog<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. 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.