Persisted OTP

A resend button that stays disabled for thirty seconds, and is still disabled after a reload. Two grafts, three injected effects, and a button that knows about none of it.

Two grafts, not one

The request and the wait are two things that happen to follow one another: one lasts as long as a network call, the other thirty seconds. Putting them in a single object would be convenient today and wrong the moment something needs only one of them — so they stay apart, and the one that depends on the other sits below it.

tsx
// -constants/graft_request.ts — the moment the request is in flight
export const GraftOtpRequest = createGraft({ name: "GraftOtpRequest", graft: useGraft });

function useGraft() {
  return useGraftReload({ delay: 900 });
}

// -constants/graft_counter.ts — the wait that follows it
export const GraftOtpCounter = createGraft({
  name: "GraftOtpCounter",
  graft: useGraft,
  inject: [useInjectRestore, useInjectPersist, useInjectStart],
});

function useGraft() {
  return useGraftCounter({ duration: OTP_DURATION });
}

Persistence lives in the graft

Two effects, and between them the whole of it: one writes down where the wait ends while it runs, the other picks it back up on arrival. Both are injected, so they belong to the graft rather than to whoever happens to render a button — mount this graft anywhere and the behaviour comes with it.

tsx
// on arrival, pick the wait back up where it was
function useInjectRestore() {
  const { start } = GraftOtpCounter.use();

  useEffect(() => {
    const deadline = Number(window.localStorage.getItem(OTP_DEADLINE_KEY));
    if (!deadline) return;

    start(Math.ceil((deadline - Date.now()) / 1000));
  }, [start]);
}

// while it runs, write down where it ends
function useInjectPersist() {
  const { counter, isCounting } = GraftOtpCounter.use();

  useEffect(() => {
    if (!isCounting) return window.localStorage.removeItem(OTP_DEADLINE_KEY);

    window.localStorage.setItem(OTP_DEADLINE_KEY, String(Date.now() + counter * 1000));
  }, [counter, isCounting]);
}

Note what is stored: the moment the wait ends, not the seconds left. A number of seconds stops being true the instant the tab closes; an instant in time is still true whenever you come back. That is also why start takes an optional number — resuming is starting with what is left.

And no window is touched during render. Effects do not run on the server, so the same graft renders there without a guard, and nothing has to be lifted into a component to keep it that way.

One rule joins them

The countdown should begin when the request lands, not when it leaves. That is a rule about the wait, so it lives with the wait — an injected hook that reads the request graft above and starts the counter on the edge between the two.

tsx
// the countdown begins when the request ends, not when it leaves
function useInjectStart() {
  const { start } = GraftOtpCounter.use();
  const { isReloading } = GraftOtpRequest.use();
  const wasSending = useRef(isReloading);

  useEffect(() => {
    if (wasSending.current && !isReloading) start();

    wasSending.current = isReloading;
  }, [isReloading, start]);
}

The button

Everything above collapses into this: disabled while sending or waiting, a label that counts down, a spinner beside it. No timer, no storage key, no idea that a reload ever happened.

tsx
// -components/resend_button.tsx
export const ResendButton = () => {
  const { counter, isCounting } = GraftOtpCounter.use();
  const { isReloading, onReload } = GraftOtpRequest.use();

  return (
    <>
      <Button disabled={isCounting || isReloading} onClick={onReload}>
        {isCounting ? `resend in ${counter}s` : "send code"}
      </Button>

      <Guard guardIf={!isReloading} shouldHide>
        <Spinner />
      </Guard>
    </>
  );
};
previewsend a code, then reload the page while it counts down