Session

A token that renews itself while it is being used, signs out on its own when it is not, and takes the other tabs with it. Three injected effects, and a panel that only shows what it is told.

session
signed out

Mounted

One provider and a panel. Sign in and the countdown restarts on its own; turn off keep renewing and watch it run out. Open this page in a second tab and sign out there — this one follows.

tsx
// -components/session_demo.tsx
export const SessionDemo = () => (
  <GraftSession.Provider>
    <SessionPanel />
  </GraftSession.Provider>
);

The graft

The token and its deadline are a hook. What the graft adds is whether the session is meant to stay alive, and three effects that act on it.

tsx
// -constants/graft_session.ts
export const GraftSession = createGraft({
  name: "GraftSession",
  graft: useGraft,
  inject: [useInjectRenew, useInjectExpire, useInjectSyncTabs],
});

function useGraft({ lifetime = SESSION_LIFETIME }: { lifetime?: number }) {
  const session = useSession({ lifetime });
  const [isRenewing, setIsRenewing] = useState(true);

  return { ...session, isRenewing, setIsRenewing };
}

Staying alive, and not

Two rules with opposite jobs, each taking the slice of the value it needs. Neither belongs to a screen: a session expires whether or not anything about authentication is being rendered.

tsx
// renew shortly before it runs out, as long as it is meant to stay alive
function useInjectRenew({ isAuthorized, isRenewing, renew, secondsLeft }: SessionValue) {
  useEffect(() => {
    if (!isAuthorized || !isRenewing || secondsLeft > SESSION_RENEW_LEAD) return;

    renew();
  }, [isAuthorized, isRenewing, renew, secondsLeft]);
}

// and when it does run out, end it
function useInjectExpire({ isAuthorized, secondsLeft, signOut }: SessionValue) {
  useEffect(() => {
    if (!isAuthorized || secondsLeft > 0) return;

    signOut();
  }, [isAuthorized, secondsLeft, signOut]);
}

Across tabs

The third watches the edge where the session ends and tells the other tabs, then listens for the same message coming back. It is the clearest case for inject: a listener that has to be there precisely when nothing on screen is about the session.

tsx
// signing out anywhere signs out everywhere
function useInjectSyncTabs({ isAuthorized, signOut }: SessionValue) {
  const wasAuthorized = useRef(isAuthorized);

  useEffect(() => {
    if (wasAuthorized.current && !isAuthorized) {
      window.localStorage.setItem(SESSION_SIGNOUT_KEY, String(Date.now()));
    }

    wasAuthorized.current = isAuthorized;
  }, [isAuthorized]);

  useEffect(() => {
    const onStorage = (event: StorageEvent) => {
      if (event.key !== SESSION_SIGNOUT_KEY) return;

      signOut();
    };

    window.addEventListener("storage", onStorage);

    return () => window.removeEventListener("storage", onStorage);
  }, [signOut]);
}

The hook underneath

Ordinary state and a tick. It knows nothing about renewal policy, expiry or tabs — those are the rules of the feature, and they live in the graft.

tsx
// -constants/use_session.ts
export const useSession = ({ lifetime }: { lifetime: number }) => {
  const [session, setSession] = useState<Session | null>(null);
  const [now, setNow] = useState(() => Date.now());

  useEffect(() => {
    if (!session) return;

    const interval = setInterval(() => setNow(Date.now()), 500);

    return () => clearInterval(interval);
  }, [session]);

  const signIn = useCallback(() => {
    setNow(Date.now());
    setSession({ token: newToken(), expiresAt: Date.now() + lifetime * 1000 });
  }, [lifetime]);

  const signOut = useCallback(() => setSession(null), []);

  const renew = useCallback(
    () => setSession((previous) => (previous ? { ...previous, expiresAt: Date.now() + lifetime * 1000 } : previous)),
    [lifetime],
  );

  return {
    token: session?.token ?? null,
    isAuthorized: session !== null,
    secondsLeft: session ? Math.max(0, Math.ceil((session.expiresAt - now) / 1000)) : 0,
    renew,
    signIn,
    signOut,
  };
};