DEV Community

loading...

Tracking Time with React Hooks

Devin Witherspoon
Frontend engineer focused on combining #a11y, testing, and developer ergonomics. Amateur photographer.
Updated on ・2 min read

Let's Talk About Time

Time is super tricky to account for in software, and one of the most common issues in frontend applications is developers forget that time keeps passing when the page is open.

Take a look at Falsehoods programmers believe about time for a starting point on the assumptions developers make.

It's really common to write a component that looks like this:

const formatter = new Intl.DateTimeFormat("en-us", {
  year: "numeric",
  month: "numeric",
  day: "numeric",
  hour: "numeric",
  minute: "numeric",
  second: "numeric",
});

const MyDateComponent = () => {
  const date = new Date();

  return formatter.format(date);
};
Enter fullscreen mode Exit fullscreen mode

See MDN Intl.DateTimeFormat for more on the date formatter used.

The issue with this component is that it doesn't update when the seconds change. This isn't as much an issue if we're not displaying seconds, but even hours and days can pass while browser tabs are open.

useDateTime

To solve this problem, I wrote useDateTime, a React hook that tracks the time to a specified precision (second/minute/hour/day), triggering a state change on each tick.

Using useDateTime to fix MyDateComponent, we get the following:

const MyDateComponent = () => {
  const date = useDateTime("second"); // second | minute | hour | day

  return formatter.format(date);
};
Enter fullscreen mode Exit fullscreen mode

This component now updates every second, keeping it accurate. We probably only want updates for every second in a clock component, and should avoid this frequency of updates for expensive renders. Outside of clocks, updating by the hour/day is much more common and something we should plan for as frontend engineers.

You can take a look at the implementation of useDateTime in this codesandbox:

The implementation uses date-fns but could be rewritten with any date library (e.g. Moment/Luxon/Day.js)

A similar hook could be used to track relative times by modifying msUntilNext to determine the next tick in increasing intervals.

Disclaimer

This component attempts to update immediately after the next tick at the specified precision. Javascript's setTimeout API does not guarantee that the timeout will trigger precisely on the target time, so the precision of this hook is also limited. Here's a good Stack Overflow Q&A summarizing this problem and a workaround.

Discussion (5)

Collapse
pke profile image
Philipp Kursawe

I just wanted to write such hook myself and can now save my time, thanks!

I think there is a little bug in the effects initial cleanup, when its still mounted but the threshold prop changes. I think the clearTimeout should be outisde the threshold check. What do you think?

useEffect(() => {
    if (timer.current) {
        clearTimeout(timer.current);
    }
    if (threshold) {
      // ...
    }
}, [threshold])  
Enter fullscreen mode Exit fullscreen mode

Also I wonder how easy the hook could be extended to support thresholds like half hours or full 15 minute intervals?

Collapse
dcwither profile image
Devin Witherspoon Author • Edited

Hi, thanks for sharing, and I'm glad it's useful to you! Full disclosure, this is a cleanup of the first custom hook I ever wrote around 2 years ago. I glossed over that part in my cleanup because I assumed it was right, but I wrote it before I fully understood hooks (even now, with scenarios like this, they're kinda tricky).

Looking closer, it seems like the clearTimeout is completely redundant with the cleanup function - which is always called whenever the threshold changes, or when the component unmounts. I've removed it which should help with the confusion. Thanks for flagging, I wouldn't have found this redundancy otherwise.

For the custom intervals, I don't think it would be hard to implement, you'd just need to change some of the math with startOfThreshold and msUntilNext. Making it fully featured in that sense, you could even add an offset to account for something like "the 3rd minute of every hour".

I think it would also be some extra work to anchor the 15 minutes against the hour, rather than any 15 minutes - though it could also be another hard coded threshold.

At some point I might write about testing this - since timeouts and intervals can be tricky to test, and there's a few interesting edge cases.

Collapse
pke profile image
Philipp Kursawe

I have extended your hook, to support amounts of thresholds (before it was just 1), converted the hook to TypeScript and remove the extra cleanup as you suggested:

function msUntilNext(threshold: Threshold, amount: number) {
  const { start, add } = thresholdMap[threshold]
  const date = new Date()
  return differenceInMilliseconds(
    add(start(date), amount),
    date
  )
}

function startOfThreshold(threshold: Threshold, amount: number) {
  const { start, add } = thresholdMap[threshold]
  return add(start(new Date()), amount)
}

export default function useDateTime(threshold: Threshold, amount = 1) {
  const [date, setDate] = useState(
    startOfThreshold(threshold, amount)
  )
  const timer = useRef<ReturnType<typeof setTimeout>>()

  useEffect(() => {
    if (threshold) {
      function delayedTimeChange() {
        timer.current = setTimeout(() => {
          delayedTimeChange()
        }, msUntilNext(threshold, amount))

        setDate(startOfThreshold(threshold, amount))
      }

      delayedTimeChange()
    }
    return () => timer.current && clearTimeout(timer.current)
  }, [threshold, amount])

  return date
}
Enter fullscreen mode Exit fullscreen mode

Then I wanted to add some tests, but I can't get it to run properly, let alone pass green.

import { act, renderHook, cleanup } from "@testing-library/react-hooks"
import { jest } from "@jest/globals"

import useDateTime from "./useDateTime"

describe("useDateTime hook", () => {
  beforeAll(() => {
    jest.useFakeTimers("modern")
  })

  afterEach(cleanup)

  afterAll(() => {
    jest.useRealTimers()
  })

  it("should report time updates every 30 minutes", async () => {
    jest.setSystemTime(new Date("2020-11-19T10:00:00.000Z"))
    const { result } = renderHook(() => useDateTime("minute"))
    act(() => {
      jest.advanceTimersByTime(1000 * 1 * 60)
      //jest.runOnlyPendingTimers()
    })
    expect(result.current.toISOString()).toEqual("2020-11-19T10:01:00.000Z")
  })
})
Enter fullscreen mode Exit fullscreen mode

Any idea how to test our hook?

Thread Thread
pke profile image
Philipp Kursawe • Edited

OK, I have the tests working now and they instantly revealed some bugs in the hook. Getting tests to run properly (for a react-native project if that matters) was the main challenge.
It involved a custom jest-preset to work around some promise related bug:

See solution and discussion

jest-preset.js

// Because fake timers did not work
// See:
// https://github.com/facebook/jest/issues/10221#issuecomment-730305710
// https://github.com/sbalay/without_await/commit/64a76486f31bdc41f5c240d28263285683755938
const reactNativePreset = require("react-native/jest-preset")

module.exports = Object.assign({}, reactNativePreset, {
  setupFiles: [require.resolve("./save-promise.js")]
    .concat(reactNativePreset.setupFiles)
    .concat([require.resolve("./restore-promise.js")]),
})
Enter fullscreen mode Exit fullscreen mode

This test now runs green:

import { act, renderHook, cleanup } from "@testing-library/react-hooks"
import { jest } from "@jest/globals"

import useDateTime from "./useDateTime"

describe("useDateTime hook", () => {
  beforeAll(() => {
    jest.useFakeTimers("modern")
  })

  afterEach(cleanup)

  afterAll(() => {
    jest.useRealTimers()
  })

  it("should report time updates every 30 minutes", () => {
    jest.setSystemTime(new Date("2020-11-19T10:00:00.000Z"))
    const { result } = renderHook(() => useDateTime("minute", 30))
    expect(result.current.toISOString()).toEqual("2020-11-19T10:00:00.000Z")
    act(() => jest.advanceTimersByTime(1000 * 30 * 60))
    expect(result.current.toISOString()).toEqual("2020-11-19T10:30:00.000Z")
  })
})
Enter fullscreen mode Exit fullscreen mode

A bug it revealed was that the hook reported the estimated future date before the timeout actually happened. In your component this was not a problem, you did not know you were one second ahead of system time ;) That happened because it called setDate outside the timeout callback.


export default function useDateTime(threshold: Threshold, amount = 1) {
  const [date, setDate] = useState(startOfThreshold(threshold))
  const timer = useRef<ReturnType<typeof setTimeout>>()

  useEffect(() => {
    if (threshold) {
      function delayedTimeChange() {
        const next = msUntilNext(threshold, amount)
        timer.current = setTimeout(() => {
+         setDate(startOfThreshold(threshold))
          delayedTimeChange()
        }, next)
-       setDate(startOfThreshold(threshold))
      }
      delayedTimeChange()
    }
    return () => timer.current && clearTimeout(timer.current)
  }, [threshold, amount])

  return date
}
Enter fullscreen mode Exit fullscreen mode

So now everything works! Thanks again for the initial work!

Thread Thread
dcwither profile image
Devin Witherspoon Author • Edited

I’ll take a look, but this change likely breaks its ability to immediately respond to threshold changes e.g. second to minute.

I also wouldn’t expect the setDate to change unless the time has actually passed by more than a second since it goes to the start of the threshold.

Again, I haven’t confirmed this yet, but that’s my intuition.

EDIT: I have confirmed the tests pass without your changes. The break may be caused by your added functionality

You can pull the tests down from useDateTime Code Sandbox with Tests in a new Create React App with "export to zip" to confirm (Code Sandbox doesn't seem to play well with @jest/globals, and export to Zip wasn't working for me either).

I've included a test that I believe will fail with your change, but you can confirm that for your implementation.