DEV Community

R.N.Krishnan
R.N.Krishnan

Posted on

I Replaced a Polling Loop With Three React Hooks and a Firestore Rule

The first version of the VORTEX dashboard polled an API endpoint every five seconds. It worked. It also meant the UI was always up to four seconds behind reality, and every agent write to Firestore required a separate read path just to surface it on screen. I replaced the whole thing with three custom hooks and onSnapshot listeners. The dashboard has been real-time since, with no polling, no message queue, and no separate read model.



The Data Model First

Before writing a single hook, I mapped out exactly which Firestore collections existed and who owned them:

Collection Writers Readers
leads Agent 1, Agent 7 Dashboard, Agent 4
activity_feed All agents (append only) Dashboard
product_intelligence Agent 6 Dashboard
agent_logs All agents Dashboard (Debate Log)

This table is the reason the security rules look the way they do:

// firestore.rules
match /activity_feed/{eventId} {
  allow read:   if true;
  allow create: if true;    // All agents append
  // No update, no delete — the feed is append-only by design
}

match /leads/{leadId} {
  allow read:  if request.auth != null;
  allow write: if request.auth != null;
}
Enter fullscreen mode Exit fullscreen mode

The activity_feed collection is append-only deliberately. No agent ever updates or deletes a feed entry. This means the feed is a reliable audit trail of what happened, in order — you can replay it from any point without worrying about entries being mutated after the fact.

The Three Hooks

The entire dashboard data layer is three hooks: useLeads, useActivityFeed, and useProductIntel. Each one owns one collection and one onSnapshot listener.

// hooks/index.jsx — useLeads
export function useLeads() {
  const [leads, setLeads] = useState(INITIAL_LEADS);

  useEffect(() => {
    const q = query(
      collection(db, 'leads'),
      orderBy('intent_score', 'desc'),
      limit(50)
    );
    return onSnapshot(q, (snapshot) => {
      const fbLeads = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
      }));
      if (fbLeads.length > 0) setLeads(fbLeads);
    });
  }, []);

  return leads;
}
Enter fullscreen mode Exit fullscreen mode

Three things worth noting here:

The if (fbLeads.length > 0) guard. Without this, an empty Firestore collection on first load would wipe the seed data. The hook falls back to INITIAL_LEADS — a hardcoded set of mock leads — until real data arrives. This means the dashboard is never blank, even before Firebase is configured.

onSnapshot returns its own unsubscribe function. Returning it directly from useEffect means React calls it on unmount, cleaning up the listener automatically. No manual cleanup needed.

orderBy('intent_score', 'desc') means the Kanban always shows highest-intent leads first within each column, without any client-side sorting logic.

The activity feed hook is similar but has a time-based limit instead:

// hooks/index.jsx — useActivityFeed
export function useActivityFeed() {
  const [events, setEvents] = useState(INITIAL_EVENTS);

  useEffect(() => {
    const q = query(
      collection(db, 'activity_feed'),
      orderBy('timestamp', 'desc'),
      limit(20)
    );
    return onSnapshot(q, (snapshot) => {
      const fbEvents = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
      }));
      if (fbEvents.length > 0) setEvents(fbEvents);
    });
  }, []);

  return events;
}
Enter fullscreen mode Exit fullscreen mode

Twenty events, most recent first. Every agent write to activity_feed triggers this listener and the feed item appears in the UI within milliseconds.

The Metrics Hook Problem

useMetrics caused the most grief. The original version returned an array:

// The version that broke everything downstream
export function useMetrics(leads) {
  return useMemo(() => [
    { label: "Total Leads", value: leads.length, trend: "+12%" },
    { label: "Hot Leads Today", value: hotLeads, trend: "+5" },
    // ...
  ], [leads]);
}
Enter fullscreen mode Exit fullscreen mode

Six components consumed this hook. Three of them destructured it as an array. Three treated it as a named object — metrics.totalLeads, metrics.hotLeads, metrics.conversionRate. The array-consuming components worked. The object-consuming components silently got undefined for every value and displayed nothing.

The fix was making useMetrics return a proper object:

export function useMetrics(leads) {
  return useMemo(() => {
    const totalLeads = leads.length;
    const hotLeads = leads.filter(l => l.status === 'HOT_LEAD').length;
    const conversionRate = 12.4;
    const highestScoreLead = leads.reduce(
      (max, l) => (!max || l.intent_score > max.intent_score ? l : max),
      null
    );
    return {
      totalLeads,
      hotLeads,
      emailsSent: 312,
      callsPlaced: 89,
      demosBooked: 12,
      conversionRate,
      highestScore: highestScoreLead?.intent_score || 0,
      highestScoreLead,
    };
  }, [leads]);
}
Enter fullscreen mode Exit fullscreen mode

The lesson: if a hook returns structured data that multiple components consume, make it a named object from day one. Arrays are fine for lists. They're not fine for typed data shapes where consumers care about specific fields.

The useCountUp Hook

The sidebar metrics animate from zero to their real value on load. That required a useCountUp hook — something the codebase was importing but that didn't exist yet.

export function useCountUp(target, duration = 1000) {
  const [value, setValue] = useState(0);
  const rafRef = useRef(null);

  useEffect(() => {
    const start = performance.now();
    const to = Number(target) || 0;

    const tick = (now) => {
      const elapsed = now - start;
      const progress = Math.min(elapsed / duration, 1);
      const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
      setValue(Math.round(to * eased));
      if (progress < 1) rafRef.current = requestAnimationFrame(tick);
    };

    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, [target, duration]);

  return value;
}
Enter fullscreen mode Exit fullscreen mode

Ease-out cubic means the number counts up fast at first and slows as it approaches the target. requestAnimationFrame keeps it tied to the display refresh rate rather than a fixed interval. The rafRef holds the animation frame ID so the cleanup function can cancel it properly on unmount — without this, switching tabs mid-animation would leave a hanging rAF loop.



The StrictMode Bug

React's StrictMode runs effects twice in development — mount, unmount, remount. This exposed a bug in the DebateTerminal component that replays the Hindsight agent log line by line:

// The broken version
useEffect(() => {
  let idx = 0;
  const show = () => {
    if (idx >= allLines.length) return;
    setDisplayed(prev => [...prev, allLines[idx]]);
    idx++;
    setTimeout(show, 60); // recursive — never cleaned up
  };
  const t = setTimeout(show, 300);
  return () => clearTimeout(t); // only cancels the first timeout
}, []);
Enter fullscreen mode Exit fullscreen mode

The cleanup only cancelled the initial 300ms delay. Once show started calling itself recursively, those timeouts had no handle. In StrictMode, the simulated unmount left the first chain running, then the remount started a second chain. Two parallel loops, both writing to displayed state with independent idx counters, producing duplicate and out-of-order lines.

The fix was storing every timeout ID in a ref:

const timeoutRef = useRef(null);

useEffect(() => {
  let idx = 0;
  const show = () => {
    if (idx >= allLines.length) { setPlaying(false); return; }
    const line = allLines[idx];
    setDisplayed(prev => [...prev, line]);
    idx++;
    timeoutRef.current = setTimeout(show, line.isHeader ? 400 : 60);
  };
  timeoutRef.current = setTimeout(show, 300);
  return () => clearTimeout(timeoutRef.current); // cancels the whole chain
}, []);
Enter fullscreen mode Exit fullscreen mode

Now the cleanup always cancels whichever timeout is currently pending. The chain breaks cleanly on unmount.

Seed Data as the Demo Mode

The hooks layer has a deliberate fallback: if Firestore returns empty or throws, the UI renders from INITIAL_LEADS and INITIAL_EVENTS. This means the entire dashboard works without a Firebase project configured — useful for demos, useful for development, useful when the backend is down.

const [leads, setLeads] = useState(INITIAL_LEADS); // fallback always set first

return onSnapshot(q, (snapshot) => {
  const fbLeads = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
  if (fbLeads.length > 0) setLeads(fbLeads); // only overwrite if real data exists
});
Enter fullscreen mode Exit fullscreen mode

The VITE_USE_DEMO_DATA environment flag extends this further — when set, the Firebase initialization is skipped entirely and the hooks return seed data without attempting any Firestore connection.

Takeaways

onSnapshot is simpler than it looks. It returns its own cleanup function, it handles reconnection automatically, and it pushes updates to all listeners simultaneously. For a dashboard that needs to reflect agent writes in real time, it's the right tool and it requires less infrastructure than a polling setup.

Return named objects from data hooks, not arrays. The useMetrics bug would have been caught immediately with TypeScript. Without it, the silent undefined failures are hard to trace because the component renders without errors — it just shows nothing.

StrictMode is a useful stress test. The DebateTerminal bug only appeared in development because of StrictMode's double-invoke behavior. That's the point — it surfaces cleanup bugs before they reach production.

Seed data is infrastructure. Having realistic fallback data in the hooks layer means the dashboard is always demonstrable, always developable, and always recoverable. It's not a hack — it's a design decision.

Closing

The dashboard started as a polling loop hitting a REST endpoint. It's now three hooks, each owning one Firestore collection, each cleaning up after itself on unmount. The real-time behavior came for free once the data model was right. The hard part wasn't the Firestore integration — it was making the hooks clean enough that six different components could consume them without knowing anything about the underlying data source.

Top comments (0)