DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A hook whose return value nobody uses

Notifio's window is full of times that go stale by themselves:

kamernet.nl · 48 listings · checked 12s ago · 1.4s
pararius.nl · blocked · Retry in 45s
Enter fullscreen mode Exit fullscreen mode

"checked 12s ago" was true when React rendered it. Nothing about the data changes as the seconds pass, so nothing triggers a re-render, so the row happily claims 12 seconds for as long as you leave the window open. A retry countdown frozen at "in 45s" is worse than no countdown, because it looks like a stuck app.

This is a small problem with three tempting bad answers, so it is worth writing down what we ended up with.

The hook

/**
 * A clock that re-renders on an interval, so relative times and countdowns
 * stay honest without every component owning a timer.
 *
 * Pass `active: false` when nothing on screen is counting down: a monitor
 * sitting idle overnight should not be re-rendering once a second.
 */
export function useNow(intervalMs = 1000, active = true): number {
  const [now, setNow] = useState(() => Date.now());

  useEffect(() => {
    if (!active) return;
    const timer = setInterval(() => setNow(Date.now()), intervalMs);
    return () => clearInterval(timer);
  }, [intervalMs, active]);

  return now;
}
Enter fullscreen mode Exit fullscreen mode

That is all of it. Here is every call site in the app:

// MonitorCard: the summary line for the whole run.
const isActive = ACTIVE_STATUSES.includes(status);
useNow(5000, isActive);

// SitesList: one row per search, with per-search retry countdowns.
const hasCountdown = sites.some((s) => {
  const nextRetryAt = app.runtimeFor(s)?.nextRetryAt;
  return nextRetryAt !== null && nextRetryAt !== undefined;
});
useNow(1000, hasCountdown || app.status === "polling");

// RecentFinds: the listings found so far.
useNow(30_000, app.finds.length > 0);
Enter fullscreen mode Exit fullscreen mode

Note what is missing: nobody uses the return value. useNow(5000, isActive) is a statement, not an assignment.

Why the value is thrown away

The components do not need to know the time. They need to be re-rendered, and the functions that format the times read the clock themselves:

/** "12s ago", "4m ago", "2h ago". Empty string for a null timestamp. */
export function relativeTime(at: number | null): string {
  if (!at) return "";
  const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
  if (seconds < 10) return "just now";
  if (seconds < 60) return `${seconds}s ago`;
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.round(hours / 24)}d ago`;
}
Enter fullscreen mode Exit fullscreen mode

The purist version takes now as a parameter, which makes it a pure function and trivially testable. I chose not to, and the reason is the call sites. checked ${relativeTime(runtime.lastCheckedAt)} appears inside a row component that is three levels below the component holding the interval, and threading now down to it means either a prop on every intermediate component or a context read in a leaf that does not otherwise need one. That is a lot of plumbing to make a display string testable when the thing worth testing is the threshold table, which you can test by injecting the timestamp instead.

So useNow returns now because a component that does arithmetic on it will want it, and the three that exist today are happy to subscribe and ignore.

If I had to defend one line of this to a reviewer, it is that the hook's name is a noun and its job is a verb. useClockTick would be a more honest name for how it is used.

The interval is a property of the thing displayed, not of the app

Three call sites, three intervals, and each one is the resolution of what that component actually shows:

  • 1 second for the search list, because it renders Retry in 45s and a countdown that skips numbers looks broken.
  • 5 seconds for the monitor summary, because its coarsest claim is "last check 2m ago" and nobody is watching a summary line for single seconds.
  • 30 seconds for the finds list, because a find is minutes old almost immediately and its row already re-renders the moment the find arrives, pushed down the app's event stream.

The finds list is the honest trade of the three: relativeTime can say "12s ago" and a 30 second tick means that string can be up to 30 seconds stale. For a list of rooms found overnight, nobody will ever notice. For a countdown, they would notice in one second.

Pick the interval from the smallest unit you render, then ask whether being one unit wrong matters. That is a shorter conversation than "how often should the UI update".

The active flag is the point of the whole exercise

This is a tray app. It runs for weeks. The window is usually hidden, and when it is not, the machine is often on battery. A one second timer that exists because some row somewhere might be counting down is exactly the kind of thing that makes a desktop app feel expensive to keep running.

So each call site has a predicate:

const ACTIVE_STATUSES: MonitorStatus[] = ["running", "idle", "polling", "replying"];
Enter fullscreen mode Exit fullscreen mode

Stopped monitor, no tick. No search in backoff and not currently polling, no tick. No finds yet, no tick. The useEffect returns early on active: false, and because active is in the dependency array the interval is created and destroyed as the condition flips, with no extra bookkeeping.

The alternative I have written before, and would not write again, is a single app-wide <ClockProvider> ticking once a second. It is less code and it re-renders every consumer in the tree on every tick forever, including the ones displaying nothing time dependent. Putting the timer in the components that display time keeps the blast radius equal to the thing that actually changed.

Two formatter details worth stealing

/** "in 45s", "in 3m". Used for the retry countdown on a blocked search. */
export function countdown(until: number): string {
  const ms = until - Date.now();
  if (ms <= 0) return "any moment";
  if (ms < 60_000) return `in ${Math.max(1, Math.round(ms / 1000))}s`;
  return `in ${Math.round(ms / 60_000)}m`;
}
Enter fullscreen mode Exit fullscreen mode

No countdown may reach zero or go negative. A countdown whose deadline has passed says "any moment", not "in 0s" and certainly not "in -3s". And the wording is deliberate: the retry happens on the next turn of the poll loop, not at the instant the clock runs out, so "now" would be a promise the app cannot keep. There is a Math.max(1, ...) in there too, so the last second reads "in 1s" rather than rounding down to "in 0s".

/** "1.4s". Shown on a row so a slow site is visible rather than guessed at. */
export function seconds(ms: number | null): string {
  if (ms === null) return "";
  return `${(ms / 1000).toFixed(1)}s`;
}
Enter fullscreen mode Exit fullscreen mode

That one is a product decision hiding in a formatter. Showing how long each check took turns "the app feels slow" into "this one site takes 4.2 seconds and the rest take one", which is the difference between a complaint and a bug report. It costs one number on a row that already exists.

The other reason all three live in one tiny format.ts: the row in the list and the summary above it were previously each formatting their own "ago" string, and they disagreed about when something became "just now". Two places rendering the same fact in different words is a bug users notice even when they cannot articulate it.

Where the times come from

Every timestamp on those rows is written by the process doing the work, not by the window displaying it, which is its own story: The window reloads every time you open it, so it cannot own the state.

If you want to see the strings themselves, the app and its live demos are at notifio.app, what each status line means is documented at notifio.app/help, and the build is at notifio.app/download.

Top comments (0)