Live Socket & Frame Hooks
The shared client-hook bundle: useLiveSocket (auto-reconnecting JSON WebSocket), useRaf, useInterval, useNow, useMounted, useMediaQuery.
"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 };
}