Skip to content

Creating a useCountdown React hook

Posted on:August 29, 2022

For my wedding website I wanted to include a countdown to count down to the big day! Here’s how I went about creating a useCountdown component.

For those of you who just want to see what the final code looks like without seeing my process, you can click me to skip to the final code.

Table of contents

Open Table of contents

Humble beginnings

We’ll start by creating a hook that will simply return zero for the days, hours, minutes, and seconds. We’ll iterate on this later.

export function useCountdown(targetDate) {
  const days = 0;
  const hours = 0;
  const minutes = 0;
  const seconds = 0;
  return { days, hours, minutes, seconds };
}

Then we’ll create some basic components to display the results of the useCountdown hook. I’m using Tailwindcss for styling, but you can style these components using whatever methods you prefer.

import { useCountdown } from "./useCountdown";

export function Countdown(targetDate) {
  const { days, hours, minutes, seconds } = useCountdown(targetDate);

  return (
    <div className="mx-auto my-3 flex max-w-min gap-6 rounded-lg border p-4">
      <CountdownItem type="days" value={days} />
      <CountdownItem type="hours" value={hours} />
      <CountdownItem type="minutes" value={minutes} />
      <CountdownItem type="seconds" value={seconds} />
    </div>
  );
}

function CountdownItem({ type, value }) {
  return (
    <div className="flex flex-col text-center text-lg">
      <span data-testid={`countdown-${type}`}>{value}</span>
      <span>{type}</span>
    </div>
  );
}

Writing the tests

Before we do too much work, let’s go ahead and write some test cases to test against so we can get instant feedback while we’re building this. We should test for a few scenarios:

import { vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { Countdown } from "../Countdown";

describe("Countdown", () => {
  beforeEach(() => {
    cleanup();
    // tell vitest we use mocked time
    vi.useFakeTimers();
  });

  afterEach(() => {
    // restoring date after each test run
    vi.useRealTimers();
  });

  it("shows positive numbers if current time is before target time", () => {
    const mockCurrentDate = new Date("2022-08-30 13:28:59.000-06:00");
    const mockTargetDate = new Date("2022-08-31 14:30:00.000-06:00");
    vi.setSystemTime(mockCurrentDate);
    render(<Countdown targetDate={mockTargetDate} />);

    const daysValue = screen.getByTestId("countdown-days").innerHTML;
    const hoursValue = screen.getByTestId("countdown-hours").innerHTML;
    const minutesValue = screen.getByTestId("countdown-minutes").innerHTML;
    const secondsValue = screen.getByTestId("countdown-seconds").innerHTML;

    expect(daysValue).toBe("1");
    expect(hoursValue).toBe("1");
    expect(minutesValue).toBe("1");
    expect(secondsValue).toBe("1");
  });

  it("shows all zeroes if current time is target time", () => {
    const mockCurrentDate = new Date("2022-08-28 11:59:59.000-06:00");
    const mockTargetDate = mockCurrentDate;
    vi.setSystemTime(mockCurrentDate);
    render(<Countdown targetDate={mockTargetDate} />);

    const daysValue = screen.getByTestId("countdown-days").innerHTML;
    const hoursValue = screen.getByTestId("countdown-hours").innerHTML;
    const minutesValue = screen.getByTestId("countdown-minutes").innerHTML;
    const secondsValue = screen.getByTestId("countdown-seconds").innerHTML;

    expect(daysValue).toBe("0");
    expect(hoursValue).toBe("0");
    expect(minutesValue).toBe("0");
    expect(secondsValue).toBe("0");
  });

  it("shows -1 seconds 1 second after target time", () => {
    const mockCurrentDate = new Date("2022-08-31 14:30:01.000-06:00");
    const mockTargetDate = new Date("2022-08-31 14:30:00.000-06:00");
    vi.setSystemTime(mockCurrentDate);
    render(<Countdown targetDate={mockTargetDate} />);

    const daysValue = screen.getByTestId("countdown-days").innerHTML;
    const hoursValue = screen.getByTestId("countdown-hours").innerHTML;
    const minutesValue = screen.getByTestId("countdown-minutes").innerHTML;
    const secondsValue = screen.getByTestId("countdown-seconds").innerHTML;

    expect(daysValue).toBe("0");
    expect(hoursValue).toBe("0");
    expect(minutesValue).toBe("0");
    expect(secondsValue).toBe("-1");
  });
});

These tests should provide decent coverage to help us to know that the hook is working.

Creating the hook

First, we need to get the difference in time between now and the target date. We can do this pretty easily by subtracting the time from each other targetDate.getTime() - new Date().getTime(). This will give us the number of milliseconds between now and the target date.

Next, we need to convert that time in milliseconds to days, hours, minutes, and seconds. The math to do that will look something like this.

const days = Math.floor(millisecondsUntilTargetDate / (1000 * 60 * 60 * 24));
const hours = Math.floor(
  (millisecondsUntilTargetDate % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
);
const minutes = Math.floor(
  (millisecondsUntilTargetDate % (1000 * 60 * 60)) / (1000 * 60)
);
const seconds = Math.floor((millisecondsUntilTargetDate % (1000 * 60)) / 1000);

We can use setInterval() to run a function to check the time left every second and then return that new value as the result of the hook. All together, the resulting hook will look something like this:

import { useEffect, useState } from "react";

export function useCountdown(targetDate) {
  const [millisecondsTillTargetTime, setMillisecondsTillTargetTime] = useState(
    targetDate.getTime() - new Date().getTime()
  );

  useEffect(() => {
    const interval = setInterval(() => {
      setMillisecondsTillTargetTime(
        targetDate.getTime() - new Date().getTime()
      );
    }, 1000);

    return () => clearInterval(interval);
  }, [targetDate]);

  return getReturnValues(millisecondsTillTargetTime);
}

const getReturnValues = (millisecondsTillTargetTime) => {
  // Calculate time left in various time intervals
  const days = Math.floor(millisecondsTillTargetTime / (1000 * 60 * 60 * 24));
  const hours = Math.floor(
    (millisecondsTillTargetTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
  );
  const minutes = Math.floor(
    (millisecondsTillTargetTime % (1000 * 60 * 60)) / (1000 * 60)
  );
  const seconds = Math.floor((millisecondsTillTargetTime % (1000 * 60)) / 1000);

  return { days, hours, minutes, seconds };
};

Most of our tests pass now! …Except for the last one. Looks like using Math.floor worked great for positive numbers, but not so great for negative numbers, so the last test case results in -1 days, -1 hours, -1 minutes, -1 seconds instead of the expected 0 days, 0 hours, 0 minutes, -1 seconds.

Let’s adjust the logic in the getReturnValues function to conditionally use Math.floor or Math.ceil depending on if the number is positive or negative.

const getReturnValues = (millisecondsTillTargetTime) => {
  // Calculate time left in various time intervals
  const days = round(millisecondsTillTargetTime / (1000 * 60 * 60 * 24));
  const hours = round(
    (millisecondsTillTargetTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
  );
  const minutes = round(
    (millisecondsTillTargetTime % (1000 * 60 * 60)) / (1000 * 60)
  );
  const seconds = round((millisecondsTillTargetTime % (1000 * 60)) / 1000);

  return { days, hours, minutes, seconds };
};

// Round down if positive number, round up if negative number
function round(value) {
  if (value > 0) {
    return Math.floor(value);
  }
  return Math.ceil(value);
}

And there we go! All of our test cases are passing!

Final code

import { useEffect, useState } from "react";

export function useCountdown(targetDate) {
  const [millisecondsTillTargetTime, setMillisecondsTillTargetTime] = useState(
    targetDate.getTime() - new Date().getTime()
  );

  useEffect(() => {
    const interval = setInterval(() => {
      setMillisecondsTillTargetTime(
        targetDate.getTime() - new Date().getTime()
      );
    }, 1000);

    return () => clearInterval(interval);
  }, [targetDate]);

  return getReturnValues(millisecondsTillTargetTime);
}

const getReturnValues = (millisecondsTillTargetTime) => {
  // Calculate time left in various time intervals
  const days = round(millisecondsTillTargetTime / (1000 * 60 * 60 * 24));
  const hours = round(
    (millisecondsTillTargetTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
  );
  const minutes = round(
    (millisecondsTillTargetTime % (1000 * 60 * 60)) / (1000 * 60)
  );
  const seconds = round((millisecondsTillTargetTime % (1000 * 60)) / 1000);

  return { days, hours, minutes, seconds };
};

// Round down if positive number, round up if negative number
function round(value) {
  if (value > 0) {
    return Math.floor(value);
  }
  return Math.ceil(value);
}