A relative-time formatter turns an absolute timestamp into a human phrase measured from the present moment — 30 seconds ago, in 2 hours, yesterday — and keeps that phrase current as real time passes. You'll build useTimeAgo, a React hook that formats a Date (or a millisecond timestamp) with Intl.RelativeTimeFormat and then re-renders itself on a self-scheduled timer, so a label that reads "5 seconds ago" quietly becomes "1 minute ago" without the parent lifting a finger. It is the reactive timestamp you see under every post, comment, and commit.
function useTimeAgo(
date: Date | number, // the instant to describe
options?: {
now?: () => number; // clock source; defaults to Date.now
locale?: string; // BCP-47 tag passed to Intl; defaults to the runtime locale
numeric?: 'auto' | 'always'; // "yesterday" vs "1 day ago"; defaults to 'auto'
}
): string; // e.g. "3 minutes ago"
Assume the clock is fixed at 2026-08-03T12:00:00Z:
useTimeAgo(new Date('2026-08-03T11:59:30Z')); // "30 seconds ago"
useTimeAgo(new Date('2026-08-03T11:58:30Z')); // "1 minute ago" (90s rounds down)
useTimeAgo(new Date('2026-08-03T14:00:00Z')); // "in 2 hours" (a future date)
useTimeAgo(new Date('2026-08-02T11:00:00Z')); // "yesterday" (numeric: 'auto')
The label also updates on its own. Mounted against a fresh timestamp it re-renders roughly once a second; once the difference is minutes wide it slows to about once a minute:
const label = useTimeAgo(comment.createdAt);
// renders "5 seconds ago", then "1 minute ago" a minute later — on its own timer.
options.now (default Date.now) so output is deterministic and unit-testable. Don't reach for Date.now() directly inside the hook.Intl over hardcoded strings. Intl.RelativeTimeFormat gives locale-correct output for free; with numeric: 'auto' a sub-second difference reads "now" — the built-in "just now".We're building a hook that answers "how long ago was this?" in words, and then keeps answering it correctly as the clock moves.
Under every comment sits a line like "3 minutes ago". It's friendlier than a raw timestamp, but it has a catch a static string doesn't: it goes out of date. Leave the page open and "3 minutes ago" should become "4 minutes ago", then "an hour ago", on its own. So the hook has two jobs — turn a Date into the right phrase, and re-render itself as time passes so the phrase stays true.
The phrasing job is a ladder. Take the gap between the target time and now, in seconds, and divide it down through the units — seconds, minutes, hours, days, weeks, months, years — stopping at the largest unit whose value is still at least 1. Ninety seconds divides to 1.5 minutes, which rounds to "1 minute ago". Whether it reads "ago" or "in" comes purely from the sign of the gap.
Intl.RelativeTimeFormat does the wording once you hand it a rounded number and a unit — format(-1, 'minute') is "1 minute ago", format(2, 'hour') is "in 2 hours". Letting it do the words is what keeps the hook correct in every locale.
The formatting is the eye-catching part, so the obvious first hook formats the date once and returns it:
function useTimeAgo(date, options = {}) {
const { now = Date.now } = options;
const timestamp = date instanceof Date ? date.getTime() : Number(date);
// Format one time, when the component mounts.
const [label] = useState(() => format(timestamp - now()));
return label;
}
The string is right the instant it renders — and then it freezes. useState's initializer runs exactly once, and there's no timer, so the value is a snapshot taken at mount. "5 seconds ago" is still "5 seconds ago" ten minutes later. We formatted the time; we never kept it alive.
const { useState, useEffect, useRef } = require('react');
// Largest-unit-first cascade. Each `amount` is how many of this unit fit in the
// next unit up: 60 seconds per minute, 60 minutes per hour, 24 hours per day...
const DIVISIONS = [
{ amount: 60, unit: 'second' },
{ amount: 60, unit: 'minute' },
{ amount: 24, unit: 'hour' },
{ amount: 7, unit: 'day' },
{ amount: 4.34524, unit: 'week' },
{ amount: 12, unit: 'month' },
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
];
// deltaMs is (target - now): negative for the past, positive for the future.
function format(deltaMs, { locale, numeric = 'auto' } = {}) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric });
let duration = deltaMs / 1000;
for (const division of DIVISIONS) {
if (Math.abs(duration) < division.amount) {
// The sign of `duration` is what makes it read "ago" vs "in".
return rtf.format(Math.round(duration), division.unit);
}
duration /= division.amount;
}
}
// How long until the label could next change, given the current magnitude.
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
function nextUpdateDelay(absMs) {
if (absMs < MINUTE) return SECOND; // seconds-scale: recheck every second
if (absMs < HOUR) return MINUTE; // minutes-scale: recheck every minute
if (absMs < DAY) return HOUR; // hours-scale: recheck every hour
return DAY; // days and beyond: once a day is plenty
}
function useTimeAgo(date, options = {}) {
const { now = Date.now, locale, numeric = 'auto' } = options;
const timestamp = date instanceof Date ? date.getTime() : Number(date);
// A counter we bump only to force a re-render; its value is never read.
const [, forceTick] = useState(0);
// Latest-value ref: the timer always reads the freshest clock, even if the
// caller passes a new `now` between renders.
const nowRef = useRef(now);
nowRef.current = now;
useEffect(() => {
let timerId;
function tick() {
const delay = nextUpdateDelay(Math.abs(nowRef.current() - timestamp));
timerId = setTimeout(() => {
forceTick((n) => n + 1); // re-render so the label below recomputes
tick(); // reschedule using the NEW magnitude
}, delay);
}
tick();
return () => clearTimeout(timerId); // stop the chain on unmount
}, [timestamp]); // restart only when the target instant changes
return format(timestamp - now(), { locale, numeric });
}
module.exports = { useTimeAgo };
Two things changed. The returned format(timestamp - now(), ...) now runs on every render, so it always reflects the current clock — and a self-scheduling setTimeout inside useEffect bumps a throwaway counter (forceTick) to trigger those renders. The effect is keyed on [timestamp] alone, so passing a fresh options object each render doesn't tear the timer down; reading now through nowRef keeps the timer on the latest clock without listing it as a dependency.
The lazy timer is setInterval(fn, 1000) — re-render every second. It's correct, but wasteful: a comment from three years ago reads "3 years ago" whether you repaint it every second or every hour, so 3599 of every 3600 wake-ups change nothing on screen. nextUpdateDelay instead returns the size of the current unit — one second while the gap is seconds-wide, one minute while it's minutes-wide, one hour while it's hours-wide. And because each fired callback calls tick() again (a setTimeout chain, not a fixed setInterval), it recomputes the delay from the new magnitude every time — so the cadence slows down on its own as the timestamp ages.
Fix now at 12:00:00 and pass a date of 11:59:01 — 59 seconds ago.
timestamp - now() is -59000ms, which divides to -59 seconds, under the 60-second cap, so the label reads "59 seconds ago". The effect runs tick(): nextUpdateDelay(59000) is under a minute, so it returns 1000 and schedules a timer one second out.12:00:02 (real timers fire a hair late — that's fine). forceTick bumps and the hook re-renders. Now the gap is -61 seconds → -1.02 minutes → rounds to -1 → "1 minute ago". tick() runs again: nextUpdateDelay(61000) is now in the minutes band, so the next timer is 60000ms out — the cadence just downshifted from seconds to minutes.clearTimeout(timerId), so the pending timer never fires and the chain ends. No callback wakes up to touch a component that's gone.useState(() => format(...)) (or useMemo(fn, [])) computes a correct string exactly once; with no timer it never updates. The label must be driven by a self-scheduled timer, not memoized at mount.setInterval(fn, 1000). Correct but profligate — it wakes every second to repaint a label that changes once an hour. Size the delay to the magnitude and reschedule with setTimeout.clearTimeout. Return a cleanup from useEffect that clears the pending id. Skip it and the timer fires after unmount, and React warns about updating a component that's no longer mounted.now or the whole options object — a new reference each render — rebuilds the timer constantly. Key it on the primitive timestamp and read now through a ref.Intl.RelativeTimeFormat with numeric: 'auto' already yields a locale-correct "now", "yesterday", and "tomorrow" for you.document.visibilityState is hidden, or while the element is off-screen via IntersectionObserver, and refresh once on return — no wake-ups for a background tab.useTimeAgo and similar libraries scale) is lighter. Note that react-use ships timing primitives like useInterval and useTimeoutFn but no relative-time hook of its own.title tooltip showing the full localized date from Intl.DateTimeFormat, so hovering reveals the exact instant.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A relative-time formatter turns an absolute timestamp into a human phrase measured from the present moment — 30 seconds ago, in 2 hours, yesterday — and keeps that phrase current as real time passes. You'll build useTimeAgo, a React hook that formats a Date (or a millisecond timestamp) with Intl.RelativeTimeFormat and then re-renders itself on a self-scheduled timer, so a label that reads "5 seconds ago" quietly becomes "1 minute ago" without the parent lifting a finger. It is the reactive timestamp you see under every post, comment, and commit.
function useTimeAgo(
date: Date | number, // the instant to describe
options?: {
now?: () => number; // clock source; defaults to Date.now
locale?: string; // BCP-47 tag passed to Intl; defaults to the runtime locale
numeric?: 'auto' | 'always'; // "yesterday" vs "1 day ago"; defaults to 'auto'
}
): string; // e.g. "3 minutes ago"
Assume the clock is fixed at 2026-08-03T12:00:00Z:
useTimeAgo(new Date('2026-08-03T11:59:30Z')); // "30 seconds ago"
useTimeAgo(new Date('2026-08-03T11:58:30Z')); // "1 minute ago" (90s rounds down)
useTimeAgo(new Date('2026-08-03T14:00:00Z')); // "in 2 hours" (a future date)
useTimeAgo(new Date('2026-08-02T11:00:00Z')); // "yesterday" (numeric: 'auto')
The label also updates on its own. Mounted against a fresh timestamp it re-renders roughly once a second; once the difference is minutes wide it slows to about once a minute:
const label = useTimeAgo(comment.createdAt);
// renders "5 seconds ago", then "1 minute ago" a minute later — on its own timer.
options.now (default Date.now) so output is deterministic and unit-testable. Don't reach for Date.now() directly inside the hook.Intl over hardcoded strings. Intl.RelativeTimeFormat gives locale-correct output for free; with numeric: 'auto' a sub-second difference reads "now" — the built-in "just now".We're building a hook that answers "how long ago was this?" in words, and then keeps answering it correctly as the clock moves.
Under every comment sits a line like "3 minutes ago". It's friendlier than a raw timestamp, but it has a catch a static string doesn't: it goes out of date. Leave the page open and "3 minutes ago" should become "4 minutes ago", then "an hour ago", on its own. So the hook has two jobs — turn a Date into the right phrase, and re-render itself as time passes so the phrase stays true.
The phrasing job is a ladder. Take the gap between the target time and now, in seconds, and divide it down through the units — seconds, minutes, hours, days, weeks, months, years — stopping at the largest unit whose value is still at least 1. Ninety seconds divides to 1.5 minutes, which rounds to "1 minute ago". Whether it reads "ago" or "in" comes purely from the sign of the gap.
Intl.RelativeTimeFormat does the wording once you hand it a rounded number and a unit — format(-1, 'minute') is "1 minute ago", format(2, 'hour') is "in 2 hours". Letting it do the words is what keeps the hook correct in every locale.
The formatting is the eye-catching part, so the obvious first hook formats the date once and returns it:
function useTimeAgo(date, options = {}) {
const { now = Date.now } = options;
const timestamp = date instanceof Date ? date.getTime() : Number(date);
// Format one time, when the component mounts.
const [label] = useState(() => format(timestamp - now()));
return label;
}
The string is right the instant it renders — and then it freezes. useState's initializer runs exactly once, and there's no timer, so the value is a snapshot taken at mount. "5 seconds ago" is still "5 seconds ago" ten minutes later. We formatted the time; we never kept it alive.
const { useState, useEffect, useRef } = require('react');
// Largest-unit-first cascade. Each `amount` is how many of this unit fit in the
// next unit up: 60 seconds per minute, 60 minutes per hour, 24 hours per day...
const DIVISIONS = [
{ amount: 60, unit: 'second' },
{ amount: 60, unit: 'minute' },
{ amount: 24, unit: 'hour' },
{ amount: 7, unit: 'day' },
{ amount: 4.34524, unit: 'week' },
{ amount: 12, unit: 'month' },
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
];
// deltaMs is (target - now): negative for the past, positive for the future.
function format(deltaMs, { locale, numeric = 'auto' } = {}) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric });
let duration = deltaMs / 1000;
for (const division of DIVISIONS) {
if (Math.abs(duration) < division.amount) {
// The sign of `duration` is what makes it read "ago" vs "in".
return rtf.format(Math.round(duration), division.unit);
}
duration /= division.amount;
}
}
// How long until the label could next change, given the current magnitude.
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
function nextUpdateDelay(absMs) {
if (absMs < MINUTE) return SECOND; // seconds-scale: recheck every second
if (absMs < HOUR) return MINUTE; // minutes-scale: recheck every minute
if (absMs < DAY) return HOUR; // hours-scale: recheck every hour
return DAY; // days and beyond: once a day is plenty
}
function useTimeAgo(date, options = {}) {
const { now = Date.now, locale, numeric = 'auto' } = options;
const timestamp = date instanceof Date ? date.getTime() : Number(date);
// A counter we bump only to force a re-render; its value is never read.
const [, forceTick] = useState(0);
// Latest-value ref: the timer always reads the freshest clock, even if the
// caller passes a new `now` between renders.
const nowRef = useRef(now);
nowRef.current = now;
useEffect(() => {
let timerId;
function tick() {
const delay = nextUpdateDelay(Math.abs(nowRef.current() - timestamp));
timerId = setTimeout(() => {
forceTick((n) => n + 1); // re-render so the label below recomputes
tick(); // reschedule using the NEW magnitude
}, delay);
}
tick();
return () => clearTimeout(timerId); // stop the chain on unmount
}, [timestamp]); // restart only when the target instant changes
return format(timestamp - now(), { locale, numeric });
}
module.exports = { useTimeAgo };
Two things changed. The returned format(timestamp - now(), ...) now runs on every render, so it always reflects the current clock — and a self-scheduling setTimeout inside useEffect bumps a throwaway counter (forceTick) to trigger those renders. The effect is keyed on [timestamp] alone, so passing a fresh options object each render doesn't tear the timer down; reading now through nowRef keeps the timer on the latest clock without listing it as a dependency.
The lazy timer is setInterval(fn, 1000) — re-render every second. It's correct, but wasteful: a comment from three years ago reads "3 years ago" whether you repaint it every second or every hour, so 3599 of every 3600 wake-ups change nothing on screen. nextUpdateDelay instead returns the size of the current unit — one second while the gap is seconds-wide, one minute while it's minutes-wide, one hour while it's hours-wide. And because each fired callback calls tick() again (a setTimeout chain, not a fixed setInterval), it recomputes the delay from the new magnitude every time — so the cadence slows down on its own as the timestamp ages.
Fix now at 12:00:00 and pass a date of 11:59:01 — 59 seconds ago.
timestamp - now() is -59000ms, which divides to -59 seconds, under the 60-second cap, so the label reads "59 seconds ago". The effect runs tick(): nextUpdateDelay(59000) is under a minute, so it returns 1000 and schedules a timer one second out.12:00:02 (real timers fire a hair late — that's fine). forceTick bumps and the hook re-renders. Now the gap is -61 seconds → -1.02 minutes → rounds to -1 → "1 minute ago". tick() runs again: nextUpdateDelay(61000) is now in the minutes band, so the next timer is 60000ms out — the cadence just downshifted from seconds to minutes.clearTimeout(timerId), so the pending timer never fires and the chain ends. No callback wakes up to touch a component that's gone.useState(() => format(...)) (or useMemo(fn, [])) computes a correct string exactly once; with no timer it never updates. The label must be driven by a self-scheduled timer, not memoized at mount.setInterval(fn, 1000). Correct but profligate — it wakes every second to repaint a label that changes once an hour. Size the delay to the magnitude and reschedule with setTimeout.clearTimeout. Return a cleanup from useEffect that clears the pending id. Skip it and the timer fires after unmount, and React warns about updating a component that's no longer mounted.now or the whole options object — a new reference each render — rebuilds the timer constantly. Key it on the primitive timestamp and read now through a ref.Intl.RelativeTimeFormat with numeric: 'auto' already yields a locale-correct "now", "yesterday", and "tomorrow" for you.document.visibilityState is hidden, or while the element is off-screen via IntersectionObserver, and refresh once on return — no wake-ups for a background tab.useTimeAgo and similar libraries scale) is lighter. Note that react-use ships timing primitives like useInterval and useTimeoutFn but no relative-time hook of its own.title tooltip showing the full localized date from Intl.DateTimeFormat, so hovering reveals the exact instant.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.