The Kit/Hooks

Live Socket & Frame Hooks

The shared client-hook bundle: useLiveSocket (auto-reconnecting JSON WebSocket), useRaf, useInterval, useNow, useMounted, useMediaQuery.

hooks.ts
"use client";

import { useEffect, useRef, useState } from "react";

/** True after the first client paint — gate time-relative / random content to avoid hydration mismatch. */
export function useMounted(): boolean {
  const [m, setM] = useState(false);
  useEffect(() => setM(true), []);
  return m;
}

/** requestAnimationFrame loop. `cb` receives the high-res timestamp. */
export function useRaf(cb: (t: number) => void, active = true): void {
  const ref = useRef(cb);
  ref.current = cb;
  useEffect(() => {
    if (!active) return;
    let raf = 0;
    const tick = (t: number) => {
      ref.current(t);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [active]);
}

/** setInterval that survives re-renders. Pass ms=null to pause. */
export function useInterval(cb: () => void, ms: number | null): void {
  const ref = useRef(cb);
  ref.current = cb;
  useEffect(() => {
    if (ms == null) return;
    const id = setInterval(() => ref.current(), ms);
    return () => clearInterval(id);
  }, [ms]);
}

/** A 1s ticker that re-renders the component — for live clocks driven by Date.now(). */
export function useNow(active = true, ms = 1000): number {
  const [now, setNow] = useState(() => Date.now());
  useInterval(() => setNow(Date.now()), active ? ms : null);
  return now;
}

/** Reactive media query. */
export function useMediaQuery(query: string): boolean {
  const [match, setMatch] = useState(false);
  useEffect(() => {
    const m = window.matchMedia(query);
    const on = () => setMatch(m.matches);
    on();
    m.addEventListener("change", on);
    return () => m.removeEventListener("change", on);
  }, [query]);
  return match;
}

export type LiveStatus = "connecting" | "open" | "closed";

export interface UseLiveSocket<T> {
  /** Last parsed message received. */
  last: T | null;
  status: LiveStatus;
}

/**
 * Generic auto-reconnecting JSON WebSocket subscription. The server pushes; the client
 * only receives. Promoted from F1Peak's LiveLink to serve any ZQ live feed.
 * Pass `url=null` to stay disconnected (e.g. nothing live to watch).
 */
export function useLiveSocket<T = unknown>(
  url: string | null,
  opts?: { onMessage?: (msg: T) => void; reconnectMs?: number },
): UseLiveSocket<T> {
  const [last, setLast] = useState<T | null>(null);
  const [status, setStatus] = useState<LiveStatus>("connecting");
  const cb = useRef(opts?.onMessage);
  cb.current = opts?.onMessage;
  const reconnectMs = opts?.reconnectMs ?? 3000;

  useEffect(() => {
    if (!url) {
      setStatus("closed");
      return;
    }
    let ws: WebSocket | null = null;
    let timer: ReturnType<typeof setTimeout> | null = null;
    let killed = false;

    const connect = () => {
      setStatus("connecting");
      try {
        ws = new WebSocket(url);
      } catch {
        timer = setTimeout(connect, reconnectMs);
        return;
      }
      ws.onopen = () => !killed && setStatus("open");
      ws.onmessage = (e) => {
        let msg: T;
        try {
          msg = JSON.parse(e.data as string) as T;
        } catch {
          return;
        }
        if (killed) return;
        setLast(msg);
        cb.current?.(msg);
      };
      ws.onclose = () => {
        if (killed) return;
        setStatus("closed");
        timer = setTimeout(connect, reconnectMs);
      };
      ws.onerror = () => ws?.close();
    };
    connect();

    return () => {
      killed = true;
      if (timer) clearTimeout(timer);
      ws?.close();
    };
  }, [url, reconnectMs]);

  return { last, status };
}
← The whole kit