Skip to Content
ExamplesTimers & time ago

Timers & time ago

Intervals are famously hard to get right with hooks. A naive useEffect(() => setInterval(...)) fails one of two ways. With empty deps, the callback goes stale. With the callback in deps, the timer resets on every render.

Getting it right takes the useInterval hook from Dan Abramov’s Making setInterval Declarative with React Hooks . It needs a ref to smuggle the latest callback past the effect’s dependency check:

import {useEffect, useRef, useState} from 'react'

/**
 * The canonical correct implementation, from Dan Abramov's
 * "Making setInterval Declarative with React Hooks"
 * https://overreacted.io/making-setinterval-declarative-with-react-hooks/
 *
 * A naive `useEffect(() => setInterval(...))` either goes stale (empty deps:
 * the callback closes over old state) or resets the timer on every render
 * (deps on the callback). The fix needs a ref to smuggle the latest callback
 * past the effect's dependency check:
 */
function useInterval(
  callback: () => void,
  delay: number | null,
) {
  const savedCallback = useRef(callback)

  // Remember the latest callback.
  useEffect(() => {
    savedCallback.current = callback
  }, [callback])

  // Set up the interval.
  useEffect(() => {
    if (delay === null) return
    const id = setInterval(
      () => savedCallback.current(),
      delay,
    )
    return () => clearInterval(id)
  }, [delay])
}

export default function App() {
  const [count, setCount] = useState(0)
  const [delay, setDelay] = useState(1000)
  const [running, setRunning] = useState(true)

  useInterval(
    () => setCount((c) => c + 1),
    running ? delay : null,
  )

  return (
    <>
      <h4>{count}</h4>
      <label>
        Delay: {delay}ms
        <input
          type="range"
          min={100}
          max={2000}
          step={100}
          value={delay}
          onChange={(e) =>
            setDelay(
              Number(e.currentTarget.value),
            )
          }
        />
      </label>
      <label>
        <input
          type="checkbox"
          checked={running}
          onChange={(e) =>
            setRunning(e.currentTarget.checked)
          }
        />
        Running
      </label>
    </>
  )
}

Open on CodeSandboxOpen Sandbox

With observables there is no mismatch to bridge. Time is just another stream. The delay and the running flag are inputs to the pipe. switchMap swaps the interval whenever either changes. scan keeps the count across swaps. Nothing to get subtly wrong:

import {useMemo, useState} from 'react'
import {
  useObservable,
  useSyncObservable,
} from 'react-rx'
import {
  combineLatest,
  interval,
  NEVER,
  scan,
  switchMap,
} from 'rxjs'
import {BehaviorSubject} from 'rxjs'

// The same demo as streams. There is nothing to get subtly wrong: the delay
// and the running flag are inputs to the stream, switchMap swaps the interval
// whenever either changes, and scan keeps the count across swaps.
export default function App() {
  const [delay$] = useState(
    () => new BehaviorSubject(1000),
  )
  const [running$] = useState(
    () => new BehaviorSubject(true),
  )

  const count$ = useMemo(
    () =>
      combineLatest([delay$, running$]).pipe(
        switchMap(([delay, running]) =>
          running ? interval(delay) : NEVER,
        ),
        scan((count) => count + 1, 0),
      ),
    [delay$, running$],
  )

  const count = useObservable(count$, 0)
  const delay = useSyncObservable(
    delay$,
    delay$.getValue(),
  )
  const running = useSyncObservable(
    running$,
    running$.getValue(),
  )

  return (
    <>
      <h4>{count}</h4>
      <label>
        Delay: {delay}ms
        <input
          type="range"
          min={100}
          max={2000}
          step={100}
          value={delay}
          onChange={(e) =>
            delay$.next(
              Number(e.currentTarget.value),
            )
          }
        />
      </label>
      <label>
        <input
          type="checkbox"
          checked={running}
          onChange={(e) =>
            running$.next(e.currentTarget.checked)
          }
        />
        Running
      </label>
    </>
  )
}

Open on CodeSandboxOpen Sandbox

A smarter time-ago

Every app with a feed has “42 seconds ago” labels. The naive version re-renders every label every second, forever.

Streams let you say precisely when a re-render is warranted:

  • One shared clock ticks every second.
  • map turns each tick into structured parts: {value, unit}. The observable emits data. Formatting stays in JSX, here via Intl.RelativeTimeFormat.
  • distinctUntilChanged drops every tick that wouldn’t change the label.

Watch the render counters below. The fresh message re-renders every second. Once a label reads “1 minute ago” it re-renders once a minute. The second message goes quiet right after it flips:

import {useMemo, useRef, useState} from 'react'
import {useObservable} from 'react-rx'

import {
  timeAgoParts$,
  toTimeAgoParts,
} from './timeAgo'

// Formatting lives in the component, not in the stream.
const rtf = new Intl.RelativeTimeFormat('en', {
  numeric: 'auto',
})

function TimeAgo({sentAt}: {sentAt: number}) {
  // Memoize the stream AND the initial value together. The initial value is
  // read on every snapshot check until the stream's first (async) emission,
  // so it must stay referentially stable. A fresh object per read would
  // loop useSyncExternalStore.
  const [parts$, initialParts] = useMemo(
    () =>
      [
        timeAgoParts$(sentAt),
        toTimeAgoParts(Date.now() - sentAt),
      ] as const,
    [sentAt],
  )
  const parts = useObservable(
    parts$,
    initialParts,
  )

  // Visible proof of how often React re-renders this label.
  const renders = useRef(0)
  renders.current += 1

  return (
    <small>
      {rtf.format(parts.value, parts.unit)} ·
      rendered {renders.current}×
    </small>
  )
}

function makeMessages(now: number) {
  return [
    {
      id: `${now}-1`,
      text: 'Just posted (re-renders every second)',
      sentAt: now - 3_000,
    },
    {
      id: `${now}-2`,
      text: 'About to turn a minute old (then goes quiet)',
      sentAt: now - 52_000,
    },
    {
      id: `${now}-3`,
      text: 'Minutes old (re-renders once a minute)',
      sentAt: now - 4.5 * 60_000,
    },
  ]
}

export default function App() {
  const [messages, setMessages] = useState(() =>
    makeMessages(Date.now()),
  )

  return (
    <>
      {messages.map((message) => (
        <article key={message.id}>
          <p>{message.text}</p>
          <TimeAgo sentAt={message.sentAt} />
        </article>
      ))}
      <button
        type="button"
        onClick={() =>
          setMessages(makeMessages(Date.now()))
        }
      >
        Restart demo
      </button>
    </>
  )
}

Open on CodeSandboxOpen Sandbox

One more thing to notice: all three labels share a single interval. The clock is one module-scoped stream, and react-rx shares one subscription per observable.

Server rendering & hydration

Time is the classic hydration hazard. The server renders at one Date.now(). The client hydrates seconds later at another. React logs a mismatch.

The stream version avoids it by making the initial value data passed from the server:

  • The server serializes its clock (serverNow) next to the HTML.
  • The client’s hydration render computes the exact same parts from it. The markup matches byte for byte. Zero mismatches.
  • The static HTML already shows the right label before any JavaScript loads.
  • The live clock takes over right after hydration.

This sandbox simulates the whole journey. renderToString produces the “server” HTML. It sits on screen for three seconds (“the bundle is downloading”). Then hydrateRoot attaches, with onRecoverableError counting any mismatches. Watch the label flip to “1 minute ago” shortly after hydration:

import {useEffect, useRef, useState} from 'react'
import {
  hydrateRoot,
  type Root,
} from 'react-dom/client'
import {renderToString} from 'react-dom/server'

import {Message, type Payload} from './Message'

function makePayload(): Payload {
  return {
    text: 'Deploy finished',
    sentAt: Date.now() - 55_000,
    serverNow: Date.now(),
  }
}

/**
 * A self-contained SSR simulation: render the message to an HTML string with
 * react-dom/server, show that static HTML for a while ("the JS bundle is
 * still downloading"), then hydrate it and count hydration mismatches.
 */
export default function App() {
  const containerRef =
    useRef<HTMLDivElement>(null)
  const [phase, setPhase] = useState<
    'server-html' | 'hydrated'
  >('server-html')
  const [mismatches, setMismatches] = useState<
    string[]
  >([])

  // What the server would serialize next to the HTML: the message and the
  // server's clock at render time. Each rerun is a fresh "request" with its
  // own id, and the container below is keyed by that id so every run renders
  // into its own element.
  const [run, setRun] = useState(() => ({
    id: 1,
    payload: makePayload(),
  }))
  const {payload} = run

  useEffect(() => {
    const el = containerRef.current
    if (!el) return

    // 1. "Server": render the HTML and ship it with the payload.
    setPhase('server-html')
    setMismatches([])
    el.innerHTML = renderToString(
      <Message {...payload} />,
    )

    // 2. Simulate a slow network: hydrate three seconds later. The label is
    //    already correct in the static HTML the whole time.
    let root: Root | undefined
    const id = setTimeout(() => {
      root = hydrateRoot(
        el,
        <Message {...payload} />,
        {
          onRecoverableError: (error) =>
            setMismatches((all) => [
              ...all,
              String(error),
            ]),
        },
      )
      setPhase('hydrated')
    }, 3000)

    return () => {
      clearTimeout(id)
      // This cleanup runs while React is committing the rerun, and a root
      // must not be unmounted synchronously mid-render. Defer it by one
      // microtask. By then the keyed container has been swapped out, so the
      // unmount tears down this run's tree (and its subscriptions) on the
      // detached element without touching the static HTML on screen.
      queueMicrotask(() => root?.unmount())
    }
  }, [payload])

  return (
    <>
      <p>
        {phase === 'server-html' ? (
          <mark>
            Static server HTML. JS still
            “downloading”…
          </mark>
        ) : (
          <ins>
            Hydrated. The label is live now
          </ins>
        )}
      </p>
      <p>
        Hydration mismatches:{' '}
        <strong>
          {mismatches.length === 0
            ? 'none'
            : mismatches.length}
        </strong>
      </p>
      {/* Keyed per run: a rerun swaps in a fresh container element, so the
          previous run's root unmounts against the old, detached one and can
          never wipe the static HTML of the run that is on screen. */}
      <div key={run.id} ref={containerRef} />
      <p>
        <button
          type="button"
          onClick={() =>
            setRun((prev) => ({
              id: prev.id + 1,
              payload: makePayload(),
            }))
          }
        >
          Run the simulation again
        </button>
      </p>
      <hr />
      <small>
        Payload serialized by the “server”:
      </small>
      <pre>
        {JSON.stringify(payload, null, 2)}
      </pre>
    </>
  )
}

Open on CodeSandboxOpen Sandbox

The mechanism is useObservable’s SSR contract. On the server it renders what the client’s first paint will show: a synchronous emission if there is one, else the initialValue. Deriving that initial value from serialized server data instead of Date.now() is all it takes.

Last updated on