useMediaQuery & useNow
StableTwo small hooks from the calendar: a media query as a boolean, and the current time that re-renders on each whole minute or second.
useMediaQuery answers a CSS media query and re-renders when the answer changes: the calendar shows its side panel only on wide screens, and the range picker shows two months instead of one. useNow returns the current time and re-renders on each whole step, lined up with the clock (every minute, on the minute, by default), which keeps the calendar’s now line and its Today labels right without a render every second.
Updated View source
Media queries
import { useSyncExternalStore } from 'react'import { useMediaQuery } from '@rchursin/product/calendar/hooks'
/** * Each row is one useMediaQuery call; it re-renders when the answer changes. Resize the window * to watch the first one. The static HTML can't know your screen, so useMediaQuery answers false * until the page has hydrated; the rows show "…" until then instead of a "No" that may be wrong. */const queries = [ { query: '(min-width: 640px)', means: 'Wider than a phone' }, { query: '(prefers-color-scheme: dark)', means: 'System set to dark' }, { query: '(prefers-reduced-motion: reduce)', means: 'Reduce motion is on' }, { query: '(pointer: coarse)', means: 'Touch is the main pointer' }, { query: '(forced-colors: active)', means: 'High contrast mode' },]
const noSubscribe = () => () => {}const useHydrated = () => useSyncExternalStore( noSubscribe,Usage
import { useMediaQuery, useNow } from '@rchursin/product/calendar/hooks'const wide = useMediaQuery('(min-width: 640px)')const now = useNow() // re-renders every minute, on the minuteconst tick = useNow(1000) // every secondExamples
The time, on the step
Every second on the left, every minute on the right.
import { useSyncExternalStore } from 'react'import { useNow } from '@rchursin/product/calendar/hooks'import { dtf } from '@rchursin/product/lib/dates'
/** * useNow(step) re-renders on each whole step: every second here, every minute by default (the * calendar's now line uses that). Prerendered HTML can't know the time, so the values appear * once the page has hydrated. */const noSubscribe = () => () => {}const useHydrated = () => useSyncExternalStore( noSubscribe, () => true, () => false, )
const withSeconds = dtf({ hour: 'numeric', minute: '2-digit', second: '2-digit' })const minutes = dtf({ hour: 'numeric', minute: '2-digit' })
export default function NowClock() { const hydrated = useHydrated() const second = useNow(1000) const minute = useNow()
return ( <dl className="grid w-full max-w-md grid-cols-1 gap-3 sm:grid-cols-2"> <div className="lux-card flex flex-col gap-1 rounded-xl bg-raised px-4 py-3.5"> <dt className="font-mono text-[10.5px] tracking-[0.12em] text-fg-3 uppercase">useNow(1000)</dt> <dd className="text-[22px] font-light tracking-[-0.02em] text-fg tabular-nums">{hydrated ? withSeconds.format(second) : '–'}</dd> </div> <div className="lux-card flex flex-col gap-1 rounded-xl bg-raised px-4 py-3.5"> <dt className="font-mono text-[10.5px] tracking-[0.12em] text-fg-3 uppercase">useNow()</dt> <dd className="text-[22px] font-light tracking-[-0.02em] text-fg tabular-nums">{hydrated ? minutes.format(minute) : '–'}</dd> </div> </dl> )}API
useMediaQuery(query)
| Prop | Type | Default | Description |
|---|---|---|---|
queryrequired | string | – | Any media query, e.g. "(min-width: 640px)" or "(prefers-reduced-motion: reduce)". |
returns | boolean | – | Whether it matches. false on the server and while a prerendered page hydrates, so the markup always matches; the real answer follows straight after. A component mounted later (in a popover, after a client-side navigation) gets the real answer on its first render. |
useNow(stepMs?)
| Prop | Type | Default | Description |
|---|---|---|---|
stepMs | number | 60_000 | Re-render on each whole step of the clock (20 ms after it, so the new minute has begun). |
returns | Date | – | The time at the last step. Starts as new Date() on the first render; keep it out of prerendered text. |
Accessibility
- Prefer CSS (sm:, motion-safe:) for layout and motion. Use useMediaQuery only when the markup itself must change, like one month or two in the range picker.
- A value that changes every second shouldn’t sit in a live region; screen readers would read it constantly. Announce milestones instead.
- prefers-color-scheme reflects the system setting, not this site’s theme switch.
Guidelines
Do
- Use the default minute step for clocks and relative times that show minutes.
- Pass a fixed date instead of useNow() in static content and tests.
Don’t
- Don’t run useNow(1000) in a list item; call it once and pass the value down.
- Don’t treat the first false as “no” where a wrong answer would mislead: in a prerendered page it means “not known yet”. Show a neutral state until hydration, as the first example does.
In use
Related
- CalendarThe whole calendar: week, day, month and agenda views, calendars told apart by pattern, and create, edit and delete.
- Week and day viewDays across, hours down: a time grid with an all-day row, overlaps side by side, and a line at the current time.
- Range pickerA start and end date for report filters, with analytics presets, typed ends and two linked months.
- Date helpersSmall date maths and Intl formatting on plain Dates: month grids, week starts from the locale, ISO weeks, relative days, durations.
Source
The component’s own files, exactly as they run on this site.
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"
/** The current time, refreshed on each whole step (default: every minute, on the minute) */export function useNow(stepMs = 60_000) { const [now, setNow] = useState(() => new Date()) useEffect(() => { let timer = 0 const schedule = () => { timer = window.setTimeout( () => { setNow(new Date()) schedule() }, stepMs - (Date.now() % stepMs) + 20, ) } schedule() return () => window.clearTimeout(timer) }, [stepMs]) return now