Timer:  The React Experience And Conclusions

Timer: The React Experience And Conclusions

Welcome, everyone, to the conclusion of this small series. We will be creating a React timer and comparing it to what we did in Svelte in the first article of the series.

Note that I have decided not to animate this timer. I ran a few searches about how to animate using React and the learning curve is daunting in comparison to learning to animate in Svelte. I just don't want to waste time learning something I know I won't be using.

We will do this one quickly (hopefully) since we already know how to make the timer and what we will need.

Step 1: Basic Markup, Styling and Number Formatting

Let's copy the formatting function over from the previous article, and return a simplified markup since we won't be animating.

Note that the variables hh, mm and ss are now states as per React's requirement. Otherwise, the UI won't update.

import { useState } from 'react';

export type TimerProps = {
  countFrom?: number;
};

function f(value) {
  if (value < 10) {
    return `0${value}`;
  }
  return value.toString();
}

function Timer({ countFrom = 0 }: TimerProps) {
  const [hh, setHh] = useState(countFrom);
  const [mm, setMm] = useState(countFrom);
  const [ss, setSs] = useState(countFrom);

  return (
    <span className="timer">
      {f(hh)}:{f(mm)}:{f(ss)}
    </span>
  );
}

export default Timer;

This produces a Timer component that shows up as 00:00:00.

Step 2: Counting Functionality

As far as I can tell, React doesn't have an equivalent to Svelte stores, and I won't be using Redux for a timer, so we'll implement the functionality the best way I know without using external libraries.

Our algorithm requires a reactive endDate value that changes whenever countFrom changes. So let's start with that:

// Outside the Timer function:
function addToDate(secs: number) {
  const c = Date.now() + secs * 1000;
  return new Date(c);
}

  // ... Inside the Timer function:
  const [endDate, setEndDate] = useState(addToDate(countFrom));

  useEffect(() => {
    setEndDate(addToDate(countFrom));
  }, [countFrom]);

Now, we want to set a new timer every time endDate changes, so we add another useEffect() call that depends on endDate:

  const [remaining, setRemaining] = useState(countFrom);

  useEffect(() => {
    setEndDate(addToDate(countFrom));
  }, [countFrom]);

  useEffect(() => {
    const interval = setInterval(() => {
      let r = Math.round((endDate.getTime() - new Date().getTime()) / 1000);
      r = Math.max(r, 0);
      setRemaining(r);
      if (r === 0) {
        clearInterval(interval);
      }
    }, 1000);

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

Ok, this one is a bit more complex. We created another state for the remaining seconds, which is the equivalent (I hope) of the readable Svelte store in the previous article. The timer callback kills the timer if zero has been reached, and the effect's cleanup function also clears the timer.

At this point, my React skills seem to not be good enough: It seems to me that I should have another clearInterval() call to kill the previous timer whenever endDate changes. So any experts out there: Correct me on this one, if you please.

Next, we code another useEffect() call depending on the value of remaining, that will calculate the final values we are after:

  useEffect(() => {
    const hours = Math.floor(remaining / 3600);
    const minutes = Math.floor((remaining - hours * 3600) / 60);
    const seconds = remaining - hours * 3600 - minutes * 60;
    setHh(hours);
    setMm(minutes);
    setSs(seconds);
  }, [remaining]);

Note the use of extra variables here: We cannot base the calculations on the state variables because they are updated in the next cycle and would therefore be out of sync by the time this effect runs.

If we were to use hh and mm here, we would see -1 in the seconds part of the timer on the turn of every minute.

With this in place, we can test. What do we see? Well, we see that the timer works, but the initial value is never shown. If we set countFrom to, say, 20 seconds, we see the first update to be 19 seconds. The Svelte timer has no trouble showing the initial value immediately.

Fixing this requires us to calculate and set the display values in the useEffect() for endDate. This will cover the initial value. The timer will cover the rest. This is the entire Timer.tsx file with the modification:

import { useEffect, useState } from 'react';

export type TimerProps = {
  countFrom?: number;
};

function f(value) {
  if (value < 10) {
    return `0${value}`;
  }
  return value.toString();
}

function addToDate(secs: number) {
  const c = Date.now() + secs * 1000;
  return new Date(c);
}

function Timer({ countFrom = 0 }: TimerProps) {
  const [hh, setHh] = useState(countFrom);
  const [mm, setMm] = useState(countFrom);
  const [ss, setSs] = useState(countFrom);
  const [endDate, setEndDate] = useState(addToDate(countFrom));

  function calculateRemainingTime() {
    const r = Math.round((endDate.getTime() - new Date().getTime()) / 1000);
    return Math.max(r, 0);
  }

  useEffect(() => {
    setEndDate(addToDate(countFrom));
  }, [countFrom]);

  useEffect(() => {
    updateTimeParts(calculateRemainingTime());
    const interval = setInterval(() => {
      let r = calculateRemainingTime();
      updateTimeParts(r);
      if (r === 0) {
        clearInterval(interval);
      }
    }, 1000);

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

  function updateTimeParts(x: number) {
    const hours = Math.floor(x / 3600);
    const minutes = Math.floor((x - hours * 3600) / 60);
    const seconds = x - hours * 3600 - minutes * 60;
    setHh(hours);
    setMm(minutes);
    setSs(seconds);
  }

  return (
    <span className="timer">
      {f(hh)}:{f(mm)}:{f(ss)}
    </span>
  );
}

export default Timer;

We have created a new function, calculateRemainingTime() to encapsulate the remaining time calculation because we are not copy/paste troglodytes. This change frees up the remaining state. We have removed it.

Test this. It shows the initial value now. The final touch is the timesup event. Well, not really an event because there are no events in React, but a callback.

We need to do 3 updates:

  1. Update the props type.

  2. Add the new prop to the Timer function's parameters.

  3. Call the callback once the timer reaches zero.

Once more, here is Timer.tsx with these modifications:

import { useEffect, useState } from 'react';

export type TimerProps = {
  countFrom?: number;
  onTimesup?: () => void
};

function f(value) {
  if (value < 10) {
    return `0${value}`;
  }
  return value.toString();
}

function addToDate(secs: number) {
  const c = Date.now() + secs * 1000;
  return new Date(c);
}

function Timer({ countFrom = 0, onTimesup }: TimerProps) {
  const [hh, setHh] = useState(countFrom);
  const [mm, setMm] = useState(countFrom);
  const [ss, setSs] = useState(countFrom);
  const [endDate, setEndDate] = useState(addToDate(countFrom));

  function calculateRemainingTime() {
    const r = Math.round((endDate.getTime() - new Date().getTime()) / 1000);
    return Math.max(r, 0);
  }

  useEffect(() => {
    setEndDate(addToDate(countFrom));
  }, [countFrom]);

  useEffect(() => {
    updateTimeParts(calculateRemainingTime());
    const interval = setInterval(() => {
      console.log('Tick from interval ID %d !!', interval);
      let r = calculateRemainingTime();
      updateTimeParts(r);
      if (r === 0) {
        clearInterval(interval);
        (onTimesup ?? (() => {}))();
      }
    }, 1000);

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

  function updateTimeParts(x: number) {
    const hours = Math.floor(x / 3600);
    const minutes = Math.floor((x - hours * 3600) / 60);
    const seconds = x - hours * 3600 - minutes * 60;
    setHh(hours);
    setMm(minutes);
    setSs(seconds);
  }

  return (
    <span className="timer">
      {f(hh)}:{f(mm)}:{f(ss)}
    </span>
  );
}

export default Timer;

Ok, this has been harder than the Svelte version. Is it done? No. I have tested it and at this point there are two problems:

  1. The timesup callback fires when my test page loads, before setting a value for countFrom.

  2. The timer shows as NaN:NaN:NaN whenever the input box that I created to set countFrom values is blanked out.

The first one is also revealing something: The timer is firing once even when there is no count set up. These 2 are fixed by means of an IF statement:

  useEffect(() => {
    updateTimeParts(calculateRemainingTime());
    let interval = 0;
    if (countFrom > 0) {
      interval = setInterval(() => {
        console.log('Tick from interval ID %d !!', interval);
        let r = calculateRemainingTime();
        updateTimeParts(r);
        if (r === 0) {
          clearInterval(interval);
          (onTimesup ?? (() => {}))();
        }
      }, 1000);
    }

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

To fix #2, we need to understand the problem. For this, I have logged the value and type of countFrom to the console: Whenever the input box is blanked out, countFrom remains of type number, but its value is NaN.

Therefore, one possible fix is to replace countFrom whenever it is NaN with a zero in any calculation.

The fix looks like this:

  function safeCountFrom() {
    return isNaN(countFrom) ? 0 : countFrom;
  }

  const [hh, setHh] = useState(safeCountFrom());
  const [mm, setMm] = useState(safeCountFrom());
  const [ss, setSs] = useState(safeCountFrom());
  const [endDate, setEndDate] = useState(addToDate(safeCountFrom()));

  // Later in the file:
  useEffect(() => {
    setEndDate(addToDate(safeCountFrom()));
  }, [countFrom]);

We created the safeCountFrom() function that replaces NaN with a zero. We use it whenever we initialize state, and whenever we calculate endDate.

Now we are truly done. We have reached Milestone 1. Let's count lines: 72 total lines - 12 blank lines = 60 lines of code.

Svelte vs React Comparison

Okay, so time to compare. Let's talk about:

  1. The net results.

  2. The time invested.

  3. The issues encountered while developing the timer.

Net Result

We reached an equivalent timer in React with a total line count of 60. In our previous article, we reached this point with Svelte in 42 lines. React needed 18 additional lines of code. Doesn't sound like much, right? Wrong. We are talking about a 43% increase in code when we use Svelte as the reference, or a 30% reduction when React is the reference.

Now this was only one component. How many components are there in your project(s)? Most medium-sized projects have around 15 to 25 components. You do your math.

We did not venture into Milestone 2 (animated timer) in React, but in Svelte, we accomplished this with 11 extra lines, bumping the Svelte source code to 53 lines. This is still less than Milestone 1 in React.

Time Invested

I certainly invested more time in the React version because its syntax and mechanics are more complex: We must use state everywhere, we must use the effect hook to calculate derived values, and we certainly spent time with issues that the Svelte counterpart never presented in the first place.

In terms of time invested in learning both technologies, I can tell you that I came to know React about 3 and a half years ago, and I came to know Svelte about 4 or 5 months ago. I have tried to master React 3 times now, by trying to finish a 400-lesson course at udemy.com. I have never gone past lesson 100. On the other hand, I learned 100% of Svelte in one day.

Issues

Reviewing the Svelte article, I documented one problem while developing: I needed to re-create the readable store on changes in countFrom.

This article, on the other hand, documented these problems while developing:

  1. The timer was not showing the initial countFrom value.

  2. The timesup callback was firing on component creation.

  3. The timer was showing NaN values whenever the countFrom value was NaN.

Note that this happened even after gaining the experience while doing the Svelte timer, which provided us with the timer algorithm. We did not have to think about the implementation; we just needed to port it over to React. I truly expected to finish it faster.

Conclusions

The code reduction that Svelte promises is very real. It is not insignificant by any standard and should be of great help when maintaining large code bases.

Svelte's more natural way of programming using reactive variables and reactive blocks works very well and seems to be more resilient to frontier cases. The Svelte timer doesn't suffer when the bound input box is blanked out, and doesn't fire the timesup event on component creation, all of it without us having to actually think about this.

Since Svelte doesn't require hooks or state and instead its compiler is smart enough to make variables reactive, looking at its codebase is far more pleasant than looking at the React codebase. This should translate into easier maintenance, and it should enable programmers with less experience or expertise to successfully work with it more often than working with React.


So, this was not fun. I think Svelte surpasses the #1 in the world in every category in the Developer Experience front. If you were to ask me, I would say Svelte came to destroy every other competitor out there.

If you haven't learned Svelte, then I recommend that you do it now. It only takes one day. Learn Svelte. Happy coding!


Just to comply, here's a live demo of the React timer we developed here: