TypeScript + React Hooks: Part 2

In this follow-up to our introductory article to using React Hooks with TypeScript, we’ll continue to discuss how leveraging types & type safety can make life easier when writing components using a handful of additional hooks provided by React.

In the previous article we introduced React Hooks, the motivations behind their addition as part of React v16.8.0, and discussed how effective they are when paired with TypeScript using the following basic hooks:

  • useState
  • useEffect
  • useMemo

In this follow-up article, we’ll be exploring some more basic Hooks provided by the React team:

  • useCallback
  • useRef
  • useReducer

useCallback<T>

This hook is functionally very similar to useMemo. However, useCallback is used to return a memoized function rather than a memoized value. Hence, a callback function passed to useCallback will only be regenerated if its dependencies change. This prevents functions from being unnecessarily generated on each render, lowering your component's memory overhead.

In fact, to see how similar these two hooks are, the following two statements are equivalent in React:

useCallback(fn, deps);
useMemo(() => fn, deps);

Similarly to using useMemo with TypeScript, the added type safety & type inference makes life easier. Here, your code will not compile if you attempt to pass the wrong argument types to your desired memoized callback function.

import React, {FC, useState} from 'react';
import MyButton from './MyButton'

const Counter: FC = () => {
  const [count, setCount] = useState(0);
  
  const memoizedIncrement = useCallback(() => {
    setCount(count + 1);
  }, [count]);
  
  const memoizedDecrement = useCallback(() => {
    setCount(count - 1);
  }, [count]);
  
  return (
    <>
      <h1>Your count is {count}</h1>
      <MyButton handleClick={memoizedIncrement}>+</MyButton>
      <MyButton handleClick={memoizedDecrement}>-</MyButton>
    </>
  );
}

The example above shows how useCallback is useful for passing a memoized version of your callback functions from a parent component to a child. As the passed function reference will remain unchanged, the child component can be optimised to reduce unnecessary re-renders.

Here, I’ve imported a custom MyButton component, which takes a function to handle clicks as a prop. Instead of calling setCount directly from each button, which will create a new function with every render, I am passing each button a reference to my memoized increment and decrement callback functions.

Additionally, with TypeScript you can use generics to define what your callback function must take as input and what it must return. Meaning that you get extra type safety with useCallback, as attempting to pass a callback that doesn’t match your defined type signature will result in a compiler error.

type MyCallback = (name: string) => string

const memoizedCallback = useCallback<MyCallback>((name: string) => {
  // do something that returns a string
}, [name]);

Looking at the type signature, we see how this works…

useCallback<T extends (...args: any[]) => any)>(callback: T, deps: DependencyList): T;

As you can see, useCallback allows you to pass a generic type T which is a function and it will return you the same type.

useRef<T>

This hook creates mutable ref objects, initialising its .current property to a passed argument. Typically, it is used to give us access to elements existing in the DOM, however since it accepts generics you can pass it any type. Let’s have a look at its type signature:

useRef<T>(initialValue: T | null): RefObject<T>

interface RefObject<T> {
  readonly current: T | null;
}

Most often, useRef is used to access properties of a DOM element imperatively. For instance, to focus an input field as the user interacts, or to clear an input field when the user successfully submits a form.

import React, {FC, useState, useRef} from 'react';

const Counter: FC = () => {
  const [count, setCount] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  
  useEffect(() => {
    if (buttonRef && buttonRef.current) {
      buttonRef.current.innerText = `${count}`;
    }
  });
  
  return (
    <>
      <h1>Click the button to increment your count!</h1>
      <button ref={buttonRef} onClick={() => setCount(count + 1)}/>
    </>
  );
}

Above I’ve added a ref to the increment button within our counter, this allows me to access the DOM element associated with it. Each time the user clicks the button, the count is incremented, and its label is updated as a side effect.

There are a couple things to note here, that will hopefully prevent headaches when you get around to using useRef. Firstly, when setting the initial value of a ref, we use null as the ref is created ahead of the real element in the DOM and therefore can momentarily be null.

Following from this, TypeScript may complain when you attempt to use your ref later - giving you the error "Object is possibly null" at compile time. Which makes sense, having looked at the type RefObject<T>, as .current is of type T | null. Therefore, you should wrap any interactions with your refs in a null check.

This is one of the very few gotchas I’ve found when using TypeScript. However, overall it makes your code safer as it ensures your DOM element exists before you can interact with it.

You may also have noticed that in the example I have utilised generics to tell the compiler exactly what type of DOM element that my ref will be targeting.

Above, when creating the button ref on line 7, I can inform the compiler that the ref is of type HTMLButtonElement. This offers additional type safety when using refs, as the code will not compile if I attempt to pass any other element type to the ref. Also, when I’m later using the ref, only the appropriate methods and fields will be exposed for that type.

useReducer

This hook offers a powerful alternative to useState, and can be particularly useful when you have some more complex state, logic involving multiple pieces of state, or logic depending on the previous state. It does so by effectively boiling down the main concepts of Redux into a hook, giving you a method to access your current state and a method of dispatching updates to the state.

useReducer takes three arguments:

  • The first argument is a reducer function, which should be familiar if you’ve used Redux before. This is used to make changes to your state. Your reducer function should be of the following type, but we’ll go through an example below:
(state: YourState, action: YourAction) => YourState
  • The following argument will contain your initial state of type YourState
  • Optionally, you can provide the hook with an init function, this is used to lazy-load your state via executing init(initialState)

useReducer then returns your state, and a dispatch function - which is used for making state changes via passing actions to your reducer function. If you’re unfamiliar with the terms dispatch, reducer, and action in the context of Redux, then I suggest checking out their glossary for a quick summary before continuing.

import React, {FC, useReducer} from 'react';

interface CounterState {
  count: number
}

type Actions = 'increment' | 'decrement'

interface Action {
  type: Actions
}

function reducer(state: CounterState, action: Action): CounterState {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

const Counter: FC = () => {
  const [state, dispatch] = useReducer(reducer, {count: 0});
  
  return (
    <>
      <h1>Your count is {state.count}</h1>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
    </>
  );
}

In the example above, I’ve adapted the Counter component to use useReducer instead of useState . It may be a bit overkill, but it serves to show a couple of cool features that TypeScript adds to ensure that your state, reducer, and actions are always type-safe with minimal overhead.

I’ve defined a reducer function that accepts types CounterState and Action as input and returns an updated CounterState. This restricts what type useReducer can accept as initial state, what type that dispatch can accept when updating the state, and ensures that the reducer can only return CounterState.

In combination, this all but ensures complete type safety, as the code will not compile if the user’s types don’t match up. Other than adding type definitions for your state and actions, there is no extra code to write, you can let the compiler do the work here.

Furthermore, defining a discriminating union type for Actions on line 7 restricts what cases the reducer can accept. As a result, if I attempt to pass anything other than the string literals 'increment' or 'decrement' as an action type then the code will not compile. This prevents the user from attempting to dispatch a non-existent action to the state, highlighting when they’ve gone wrong.

Another useful way to utilise useReducer is to pass its dispatch function where you may have used a callback in the past, this allows for performance optimisations - particularly if you have more complex, nested pieces of state.

...

interface MyButtonProps {
  dispatch: (action: Action) => void;
  action: Action;
  label: string;
}

const MyButton: FC<MyButtonProps> = ({ dispatch, action, label }) => {
  const handleClick = () => dispatch(action);

  return <button onClick={handleClick}>{label}</button>;
};

const Counter: FC = () => {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <>
      <h1>Your count is {state.count}</h1>
      <MyButton dispatch={dispatch} action={{ "{{type: 'increment'" }}}} label="+" />
      <MyButton dispatch={dispatch} action={{ "{{type: 'decrement'" }}}} label="-" />
    </>
  );
};

Above, I’ve adapted the example from useCallback where I pass a callback to my custom button component to update the count. Instead, showing how we can achieve something similar using useReducer and passing dispatch from parent to child.

Wrapping Up

React Hooks are a powerful tool that can be used to reduce boilerplate, lower the barrier to entry for less-experienced front-end developers, and can make our code more organised and easier to reason about.

Adding TypeScript to the mix can make them even greater, leveraging its support for generics, a powerful type inference system, and the type safety it provides. The best part, in my opinion, is how low the overhead is - as we often have to write little to no extra code to reap these benefits.

Make sure to keep an eye out for our next article in this series, where we’ll discuss how we can supercharge React Hooks by composing them to write our own Custom Hooks, and how incredibly useful sharing these hooks across components can be.

Article By
blog author

Ross Jenkins

Software Engineer