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

proptypedefault
graft(options) => TValuethe hook the provider mounts
inject?((value: TValue) => void)[][]hooks run inside the provider
name?string"Graft"shown in DevTools and in the error

Returns

proptypedefault
Provider(props: TOptions) => ReactNodemounts the hook
use() => TValuereads it, throws outside the provider
displayNamestringthe 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.

tsx
// -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.

tsx
// -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.

tsx
// -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.

tsx
// -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.

tsx
// 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.

tsx
// -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.