Custom Hooks

React hooks that bridge the API client, Zustand stores, and TanStack Query for the MetaForge Digital Twin Dashboard. Every data-fetching hook uses TanStack Query v5 for caching, deduplication, and background refetching. WebSocket events trigger targeted query invalidations so the UI stays in sync without polling.


src/hooks/use-websocket.ts

Lifecycle hook that opens (and tears down) the WebSocket connection from the websocket store.

import { useEffect } from "react";
import { useWebSocketStore } from "@/store/websocket-store";

const WS_URL = import.meta.env.VITE_WS_URL ?? `ws://${window.location.host}/ws`;

/**
 * Initialise the WebSocket connection on mount and disconnect on unmount.
 * Safe to call from multiple components — the store prevents duplicate
 * connections.
 */
export function useWebSocket() {
  const status = useWebSocketStore((s) => s.status);
  const lastMessage = useWebSocketStore((s) => s.lastMessage);
  const send = useWebSocketStore((s) => s.send);
  const connect = useWebSocketStore((s) => s.connect);
  const disconnect = useWebSocketStore((s) => s.disconnect);

  useEffect(() => {
    connect(WS_URL);
    return () => disconnect();
  }, [connect, disconnect]);

  return { status, lastMessage, send } as const;
}

src/hooks/use-event-stream.ts

Subscribes to incoming WebSocket messages and maps each event type to the appropriate TanStack Query invalidation so cached data is refreshed automatically.

import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useWebSocketStore, type WebSocketMessage } from "@/store/websocket-store";

// ---------------------------------------------------------------------------
// Event-to-query mapping
// ---------------------------------------------------------------------------

interface InvalidationRule {
  /** Query keys to invalidate when this event arrives. */
  keys: readonly (readonly string[])[];
}

const EVENT_MAP: Record<string, InvalidationRule> = {
  "session.started": {
    keys: [["sessions"], ["status"]],
  },
  "agent.progress": {
    keys: [["agents"]],
    // NOTE: session-specific invalidation is handled below because the
    // session ID comes from the event payload.
  },
  "session.complete": {
    keys: [["sessions"], ["pending"], ["status"]],
  },
  "bom.updated": {
    keys: [["bom"], ["bom-risk"], ["supply-chain-risks"]],
  },
  "test.completed": {
    keys: [["testing"], ["compliance"]],
  },
  "approval.requested": {
    keys: [["approvals"], ["pending"], ["sessions"]],
  },
  "compliance.updated": {
    keys: [["compliance"]],
  },
};

// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------

/**
 * Listens to the WebSocket message stream and keeps TanStack Query caches
 * fresh by invalidating the right keys whenever a server event arrives.
 *
 * Returns the latest event so components can display toasts or banners.
 */
export function useEventStream() {
  const queryClient = useQueryClient();
  const lastMessage = useWebSocketStore((s) => s.lastMessage);
  const [latestEvent, setLatestEvent] = useState<WebSocketMessage | null>(null);
  const prevRef = useRef<WebSocketMessage | null>(null);

  useEffect(() => {
    // Only react to genuinely new messages.
    if (!lastMessage || lastMessage === prevRef.current) return;
    prevRef.current = lastMessage;
    setLatestEvent(lastMessage);

    const rule = EVENT_MAP[lastMessage.event];
    if (!rule) return;

    // Invalidate every listed query key.
    for (const key of rule.keys) {
      queryClient.invalidateQueries({ queryKey: [...key] });
    }

    // agent.progress carries a session ID — invalidate that specific session.
    if (
      lastMessage.event === "agent.progress" &&
      typeof lastMessage.data === "object" &&
      lastMessage.data !== null &&
      "session_id" in lastMessage.data
    ) {
      const sessionId = (lastMessage.data as { session_id: string }).session_id;
      queryClient.invalidateQueries({ queryKey: ["session", sessionId] });
    }

    // session.complete also carries a session ID.
    if (
      lastMessage.event === "session.complete" &&
      typeof lastMessage.data === "object" &&
      lastMessage.data !== null &&
      "session_id" in lastMessage.data
    ) {
      const sessionId = (lastMessage.data as { session_id: string }).session_id;
      queryClient.invalidateQueries({ queryKey: ["session", sessionId] });
    }
  }, [lastMessage, queryClient]);

  return { latestEvent };
}

src/hooks/use-sessions.ts

TanStack Query hooks for session CRUD.

import {
  useQuery,
  useMutation,
  useQueryClient,
  type UseQueryOptions,
} from "@tanstack/react-query";
import {
  getSessions,
  getSession,
  createSession,
  deleteSession,
  type GetSessionsParams,
  type CreateSessionPayload,
} from "@/api/endpoints/sessions";
import type { PaginatedResponse, Session } from "@/types/session";

// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------

export function useSessions(
  params?: GetSessionsParams,
  options?: Partial<UseQueryOptions<PaginatedResponse<Session>>>,
) {
  return useQuery<PaginatedResponse<Session>>({
    queryKey: ["sessions", params],
    queryFn: () => getSessions(params),
    staleTime: 30_000,
    ...options,
  });
}

export function useSession(
  id: string | undefined,
  options?: Partial<UseQueryOptions<Session>>,
) {
  return useQuery<Session>({
    queryKey: ["session", id],
    queryFn: () => getSession(id!),
    enabled: !!id,
    staleTime: 10_000,
    ...options,
  });
}

// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------

export function useCreateSession() {
  const queryClient = useQueryClient();

  return useMutation<Session, Error, CreateSessionPayload>({
    mutationFn: createSession,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["sessions"] });
      queryClient.invalidateQueries({ queryKey: ["status"] });
    },
  });
}

export function useDeleteSession() {
  const queryClient = useQueryClient();

  return useMutation<void, Error, string>({
    mutationFn: deleteSession,
    onSuccess: (_data, sessionId) => {
      queryClient.invalidateQueries({ queryKey: ["sessions"] });
      queryClient.removeQueries({ queryKey: ["session", sessionId] });
      queryClient.invalidateQueries({ queryKey: ["status"] });
    },
  });
}

src/hooks/use-agents.ts

Agent listing and per-agent status polling.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getAgents, getAgentStatus } from "@/api/endpoints/agents";
import type { Agent } from "@/types/agent";

/**
 * Fetch all registered agents.
 */
export function useAgents(options?: Partial<UseQueryOptions<Agent[]>>) {
  return useQuery<Agent[]>({
    queryKey: ["agents"],
    queryFn: getAgents,
    staleTime: 60_000,
    ...options,
  });
}

/**
 * Poll a single agent's execution status.
 *
 * @param code - Agent identifier (e.g. "REQ", "EE", "FW").
 */
export function useAgentStatus(
  code: string | undefined,
  options?: Partial<UseQueryOptions<Agent>>,
) {
  return useQuery<Agent>({
    queryKey: ["agent", code],
    queryFn: () => getAgentStatus(code!),
    enabled: !!code,
    refetchInterval: 5_000,
    staleTime: 3_000,
    ...options,
  });
}

src/hooks/use-health.ts

System health and status with automatic background polling.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getHealth, getStatus } from "@/api/endpoints/health";
import type { HealthResponse, StatusResponse } from "@/types/system";

/**
 * Lightweight liveness probe — polled every 30 seconds.
 */
export function useHealth(options?: Partial<UseQueryOptions<HealthResponse>>) {
  return useQuery<HealthResponse>({
    queryKey: ["health"],
    queryFn: getHealth,
    refetchInterval: 30_000,
    staleTime: 25_000,
    ...options,
  });
}

/**
 * Detailed system status — polled every 10 seconds.
 */
export function useStatus(options?: Partial<UseQueryOptions<StatusResponse>>) {
  return useQuery<StatusResponse>({
    queryKey: ["status"],
    queryFn: getStatus,
    refetchInterval: 10_000,
    staleTime: 8_000,
    ...options,
  });
}

src/hooks/use-bom.ts

Bill of Materials data and risk analysis.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getBOM, getBOMRisk } from "@/api/endpoints/bom";
import type { BOMEntry, BOMRiskSummary } from "@/types/bom";

/**
 * Fetch the full BOM table.
 */
export function useBOM(options?: Partial<UseQueryOptions<BOMEntry[]>>) {
  return useQuery<BOMEntry[]>({
    queryKey: ["bom"],
    queryFn: getBOM,
    staleTime: 60_000,
    ...options,
  });
}

/**
 * Fetch BOM risk analysis (single-source, EOL, lead-time flags).
 */
export function useBOMRisk(
  options?: Partial<UseQueryOptions<BOMRiskSummary>>,
) {
  return useQuery<BOMRiskSummary>({
    queryKey: ["bom-risk"],
    queryFn: getBOMRisk,
    staleTime: 60_000,
    ...options,
  });
}

src/hooks/use-compliance.ts

Per-market regulatory compliance data.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getCompliance } from "@/api/endpoints/compliance";
import type { ComplianceMarket, ComplianceSummary } from "@/types/compliance";

/**
 * Fetch compliance status for a specific target market.
 *
 * @param market - One of "UKCA", "CE", or "FCC".
 */
export function useCompliance(
  market: ComplianceMarket | undefined,
  options?: Partial<UseQueryOptions<ComplianceSummary>>,
) {
  return useQuery<ComplianceSummary>({
    queryKey: ["compliance", market],
    queryFn: () => getCompliance(market!),
    enabled: !!market,
    staleTime: 120_000,
    ...options,
  });
}

src/hooks/use-digital-thread.ts

Digital-thread graph and single-requirement traceability.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import {
  getDigitalThread,
  getTraceability,
} from "@/api/endpoints/digital-thread";
import type { DigitalThreadGraph } from "@/types/digital-thread";

/**
 * Fetch the full digital-thread graph.
 */
export function useDigitalThread(
  options?: Partial<UseQueryOptions<DigitalThreadGraph>>,
) {
  return useQuery<DigitalThreadGraph>({
    queryKey: ["digital-thread"],
    queryFn: getDigitalThread,
    staleTime: 60_000,
    ...options,
  });
}

/**
 * Trace a single requirement through the digital thread.
 */
export function useTraceability(
  reqId: string | undefined,
  options?: Partial<UseQueryOptions<DigitalThreadGraph>>,
) {
  return useQuery<DigitalThreadGraph>({
    queryKey: ["traceability", reqId],
    queryFn: () => getTraceability(reqId!),
    enabled: !!reqId,
    staleTime: 30_000,
    ...options,
  });
}

src/hooks/use-supply-chain.ts

Supply-chain risk data.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getSupplyChainRisks } from "@/api/endpoints/supply-chain";
import type { SupplyChainRisk } from "@/types/supply-chain";

/**
 * Fetch all current supply-chain risk entries.
 */
export function useSupplyChainRisks(
  options?: Partial<UseQueryOptions<SupplyChainRisk[]>>,
) {
  return useQuery<SupplyChainRisk[]>({
    queryKey: ["supply-chain-risks"],
    queryFn: getSupplyChainRisks,
    staleTime: 60_000,
    ...options,
  });
}

src/hooks/use-approvals.ts

Approval queue with approve/reject mutations.

import {
  useQuery,
  useMutation,
  useQueryClient,
  type UseQueryOptions,
} from "@tanstack/react-query";
import {
  getPendingApprovals,
  approveSession,
  rejectSession,
} from "@/api/endpoints/approvals";
import type { Approval } from "@/types/approval";

/**
 * Fetch all pending approvals. Refetches every 15 seconds.
 */
export function useApprovals(
  options?: Partial<UseQueryOptions<Approval[]>>,
) {
  return useQuery<Approval[]>({
    queryKey: ["approvals"],
    queryFn: getPendingApprovals,
    refetchInterval: 15_000,
    staleTime: 10_000,
    ...options,
  });
}

/**
 * Approve a pending session.
 */
export function useApproveSession() {
  const queryClient = useQueryClient();

  return useMutation<void, Error, { sessionId: string; comment?: string }>({
    mutationFn: ({ sessionId, comment }) =>
      approveSession(sessionId, comment),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["approvals"] });
      queryClient.invalidateQueries({ queryKey: ["pending"] });
      queryClient.invalidateQueries({ queryKey: ["sessions"] });
      queryClient.invalidateQueries({ queryKey: ["status"] });
    },
  });
}

/**
 * Reject a pending session.
 */
export function useRejectSession() {
  const queryClient = useQueryClient();

  return useMutation<void, Error, { sessionId: string; reason: string }>({
    mutationFn: ({ sessionId, reason }) =>
      rejectSession(sessionId, reason),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["approvals"] });
      queryClient.invalidateQueries({ queryKey: ["pending"] });
      queryClient.invalidateQueries({ queryKey: ["sessions"] });
      queryClient.invalidateQueries({ queryKey: ["status"] });
    },
  });
}

src/hooks/use-testing.ts

Test-coverage metrics.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getTestCoverage } from "@/api/endpoints/testing";
import type { TestCoverage } from "@/types/testing";

/**
 * Fetch aggregate test-coverage data.
 */
export function useTestCoverage(
  options?: Partial<UseQueryOptions<TestCoverage>>,
) {
  return useQuery<TestCoverage>({
    queryKey: ["testing"],
    queryFn: getTestCoverage,
    staleTime: 60_000,
    ...options,
  });
}

src/hooks/use-gates.ts

Gate-readiness checks for hardware milestones.

import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { getGateReadiness } from "@/api/endpoints/gates";
import type { GateReadiness, GateName } from "@/types/gate";

/**
 * Fetch readiness status for a hardware milestone gate.
 *
 * @param gate - One of "EVT", "DVT", or "PVT".
 */
export function useGateReadiness(
  gate: GateName | undefined,
  options?: Partial<UseQueryOptions<GateReadiness>>,
) {
  return useQuery<GateReadiness>({
    queryKey: ["gate-readiness", gate],
    queryFn: () => getGateReadiness(gate!),
    enabled: !!gate,
    staleTime: 60_000,
    ...options,
  });
}

The following chat hooks are specified in Agent Chat Channel: useChatThreads(), useChatThread(), useChatChannels(), useCreateChatThread(), useSendChatMessage().