jonelantha: Blog


React: How to get out of useCallback & useRef hell

15th January 2021

A look at different refactoring approaches when the hooks start to take over...

There can be no question that moving into a purely functional React world wouldn't be possible without useCallback and useRef. Between them they provide a big chunk of the functionality that class based components previously offered:

  • useRef is great for supporting variables, much like class instance variables.
  • useCallback is essential for providing stable functions (like class methods) - useful for avoiding unnecessary renders.

The downside is both these hooks bring some clutter to the code - which can make complex custom hooks harder to understand and work with.

An example

Here's a simple custom hook which logs a timer to the console, once every second.

The timer starts and stops automatically on mount & unmount but can also be manually controlled with the startTimer / stopTimer functions (which are returned from the hook):

export function useTimerLogger() {
  // the id from setInterval, required for stopping the timer
  const intervalIdRef = useRef(null);

  // current value of the timer
  const timerValueRef = useRef(0);

  const startTimer = useCallback(() => {
    if (intervalIdRef.current) return;

    intervalIdRef.current = setInterval(() => {
      timerValueRef.current++;

      console.log(`Timer: ${timerValueRef.current}`);
    }, 1000);
  }, []);

  const stopTimer = useCallback(() => {
    if (!intervalIdRef.current) return;

    clearInterval(intervalIdRef.current);
    intervalIdRef.current = 0;
  }, []);

  useEffect(() => {
    // start timer when component mounts
    startTimer();

    // stop when component is unmounted
    return () => stopTimer();
  }, [startTimer, stopTimer]);

  return { startTimer, stopTimer };
}

intervalIdRef and timerValueRef are managed with useRef rather than useState - these variables are internal to the hook and updating them shouldn't cause a re-render. Likewise startTimer / stopTimer are created using useCallback; the hook should return stable function references, again to avoid unecessary re-renders.

This was a fairly simple example but the clutter from useCallback and useRef/.current can already be seen... so just imagine what happens with more state and more methods.

So what can be done to make things more readable?

One approach is to try and split things up into smaller custom hooks. But sometimes this just isn't possible without creating odd abstractions (which can often be harder to reason about than the original code).

Another approach is to try and move the variables and functions inside the useEffect call (so no need for useRef and useCallback). This isn't possible here as the logic needs to also be available outside the useEffect hook (because the startTimer / stopTimer are returned from the hook and need to be available outside useEffect).

So what other options are there? What's needed here is a way to co-locate the state and functions in some neatly organised package... a bit like a class? 🤔

1. Refactor into a class

How about actually using a class... within the hook? 😳

class TimerLogger {
  #intervalId = null;
  #timerLogger = 0;

  startTimer = () => {
    if (this.#intervalId) return;

    this.#intervalId = setInterval(() => {
      this.#timerLogger++;

      console.log(`Timer: ${this.#timerLogger}`);
    }, 1000);
  };

  stopTimer = () => {
    if (!this.#intervalId) return;

    clearInterval(this.#intervalId);
    this.#intervalId = 0;
  };
}

export function useTimerLogger() {
  const timerLoggerRef = useRef(null);
  timerLoggerRef.current ??= new TimerLogger();

  useEffect(() => {
    timerLoggerRef.current.startTimer();

    return () => timerLoggerRef.current.stopTimer();
  }, []);

  return timerLoggerRef.current;
}

So here all the logic and state is nicely organised in the TimerLogger class, the hook just manages the lifecycle side of things. There is still one useRef to store the single instance of the TimerLogger class but thanks to the ??= logical assignment operator the instantiation is quite concise. Also, it will be fine to return the actual created instance of the class so consumers of the hook can call the stable startTimer and stopTimer methods directly.

2. Refactor into a higher order function

This is a very similar approach to above but a higher order function is used; a function returning a set of functions. The internal variables are managed as let variables inside the function.

import { useEffect, useRef } from 'react';

export function useTimerLogger() {
  const timerLoggerRef = useRef(null);
  timerLoggerRef.current ??= createTimerLogger();

  useEffect(() => {
    timerLoggerRef.current.startTimer();

    return () => timerLoggerRef.current.stopTimer();
  }, []);

  return timerLoggerRef.current;
}

function createTimerLogger() {
  let intervalId = null;
  let timerValue = 0;

  return {
    startTimer() {
      if (intervalId) return;

      intervalId = setInterval(() => {
        ++timerValue;

        console.log(`Timer: ${timerValue}`);
      }, 1000);
    },

    stopTimer() {
      if (!intervalId) return;

      clearInterval(intervalId);
      intervalId = 0;
    },
  };
}

Nice! Very similar characteristics to using a class but without the guilt! 😅

A little less verbose - ultimately it comes down to taste.

3. Change the hook's interface

Finally it's just possible that returning methods isn't the React way. If instead the hook's interface is changed to replace the startTimer / stopTimer methods with a paused parameter then suddenly things get much simpler:

export function useTimerLogger(paused) {
  const timerValueRef = useRef(0);

  useEffect(() => {
    if (paused) return;

    const intervalId = setInterval(() => {
      timerValueRef.current++;

      console.log(`Timer: ${timerValueRef.current}`);
    }, 1000);

    return () => clearInterval(intervalId);
  }, [paused]);
}

Almost half the code.

However, changing the hook's interface is a luxury which isn't always available - but it's certainly an avenue worth exploring.

Conclusion

Using a separate class or higher order function can offer a clean alternative to managing complex state and methods directly inside the hook. It may also be worth having another look at the interface of the hook to see if there's a more 'React hooks' way to do things.

Ultimately it comes down to circumstances and taste but it's definately worth looking at all the parts in the JavaScript box and not just the hooks 😉

Thanks for reading!


© 2003-2024 jonelantha