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.
Mounted
Two providers and a button. Reload the page while it counts and the wait is still there.
// -components/resend_gate.tsx
export const ResendGate = () => (
<GraftOtpRequest.Provider>
<GraftOtpCounter.Provider>
<ResendButton />
</GraftOtpCounter.Provider>
</GraftOtpRequest.Provider>
);
// -components/resend_button.tsx
export const ResendButton = () => {
const { counter, isCounting } = GraftOtpCounter.use();
const { isSending, send } = GraftOtpRequest.use();
return (
<>
<Button disabled={isCounting || isSending} onClickBlur={send}>
<Button.Label label={isCounting ? `resend in ${counter}s` : "send code"} />
</Button>
<Guard guardIf={!isSending} shouldHide>
<Spinner />
</Guard>
</>
);
};Two grafts, not one
The request lasts as long as a network call, the wait thirty seconds. Keeping them apart means the one that depends on the other simply sits below it.
// -constants/graft_request.ts — the moment the request is in flight
export const GraftOtpRequest = createGraft({ name: "GraftOtpRequest", graft: useGraft });
function useGraft() {
return useSending({ delay: OTP_SEND_DELAY });
}
// -constants/graft_counter.ts — the wait that follows it
export const GraftOtpCounter = createGraft({
name: "GraftOtpCounter",
graft: useGraft,
inject: [useInjectRestore, useInjectPersist, useInjectStart],
});
function useGraft() {
return useCountdown({ duration: OTP_DURATION });
}Persistence, injected
One effect writes down where the wait ends, the other picks it back up on arrival. Both take the value of their graft as an argument, so mounting the provider is all it takes for the behaviour to come with it.
// on arrival, pick the wait back up where it was
function useInjectRestore({ start }: { start: (seconds?: number) => void }) {
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({ counter, isCounting }: { counter: number; isCounting: boolean }) {
useEffect(() => {
if (!isCounting) return window.localStorage.removeItem(OTP_DEADLINE_KEY);
window.localStorage.setItem(OTP_DEADLINE_KEY, String(Date.now() + counter * 1000));
}, [counter, isCounting]);
}What is stored is 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 does not. And no window is touched during render, so the same graft renders on the server without a guard.
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 — reading its own value from the argument and the request graft above with use().
// the countdown begins when the request ends, not when it leaves
function useInjectStart({ start }: { start: (seconds?: number) => void }) {
const { isSending } = GraftOtpRequest.use();
const wasSending = useRef(isSending);
useEffect(() => {
if (wasSending.current && !isSending) start();
wasSending.current = isSending;
}, [isSending, start]);
}The hooks underneath
Nothing here comes from the library. Both are ordinary hooks, sitting in the example beside the grafts that mount them.
// -constants/use_sending.ts
export const useSending = ({ delay }: { delay: number }) => {
const [isSending, setIsSending] = useState(false);
const send = useCallback(() => setIsSending(true), []);
useEffect(() => {
if (!isSending) return;
const timer = setTimeout(() => setIsSending(false), delay);
return () => clearTimeout(timer);
}, [delay, isSending]);
return { isSending, send };
};
// -constants/use_countdown.ts — counts against a deadline, not against ticks
export const useCountdown = ({ duration }: { duration: number }) => {
const [counter, setCounter] = useState(0);
const [isCounting, setIsCounting] = useState(false);
const deadlineRef = useRef<number | null>(null);
const start = useCallback(
(seconds = duration) => {
const next = Math.max(0, seconds);
if (!next) return;
deadlineRef.current = Date.now() + next * 1000;
setCounter(next);
setIsCounting(true);
},
[duration],
);
useEffect(() => {
if (!isCounting) return;
const interval = setInterval(() => {
const deadline = deadlineRef.current;
if (deadline === null) return;
const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
setCounter(remaining);
if (remaining > 0) return;
deadlineRef.current = null;
setIsCounting(false);
}, 1000);
return () => clearInterval(interval);
}, [isCounting]);
return { counter, isCounting, start };
};