DEV Community

Discussion on: Tracking Time with React Hooks

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 • 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 • 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.