Using React Hooks with TypeScript

Using React Hooks with TypeScript

React and TypeScript are a powerful combination for building safer, more scalable web applications.

Unfortunately, not every pattern adopted by the JavaScript community translates nicely into TypeScript. React higher-order components, for example, enable code reuse by wrapping common logic around new, dynamically-created components. “Make it up as you go” is a respected (if not expected) tenet in JavaScript philosophy, but dynamically-created interfaces can be tricky (if not impossible) to describe generally at compile-time.

Enter hooks, an alternative, TypeScript-friendly pattern for code reuse. They’re ready to plug into your otherwise typesafe React application, and they’ll respect existing constraints in your code. Just update react, react-dom to >=16.8.0:

$ npm i --save \
  react@16.8.0-alpha.1 \
  react-dom@16.8.0-alpha.1

We’ll need updated typings, too. Hook definitions have existed in latest since v16.7.0:

$ npm i --save \
  @types/react@16.7.0 \
  @types/react-dom@16.7.0

With type definitions and a working TypeScript config, we’re ready to start using hooks.

Example: typed useReducer()

Let’s rewrite the classic counter example using React’s useReducer hook. The reducer in question is exactly the same (by implementation, signature, and type definitions) as you might use in a typesafe redux application.

// reducer.ts
type Action = 'INCREMENT' | 'DECREMENT';

type State = {
  count: number;
};

const reducer = (state: State, action: Action) => {
  switch (action) {
    case 'INCREMENT':
      return {count: state.count + 1};
    case 'DECREMENT':
      if (state.count === 0) {
        return state;
      }
      return {count: state.count - 1};
    default:
      return state;
  }
};

export default reducer;

Instead of a central redux store, however, React’s useReducer() hook now takes over as our state container.

import * as React from 'react';
import reducer from './reducer';

const Counter: React.SFC<{}> = () => {
  const [state, dispatch] = React.useReducer(reducer, {count: 0});

  return (
    <div>
      <h1>Count: {state.count}</h1>
      <div>
        <button onClick={() => dispatch('INCREMENT')}>+1</button>
        <button onClick={() => dispatch('DECREMENT')}>-1</button>
      </div>
    </div>
  );
};

The best part? The useReducer types defined out of the box will infer the shape of our reducer and correctly raise the obvious errors. Try changing count to a string—even without explicit type annotations, TypeScript will correctly infer that state.count needs to be a number.

Was that too easy?

So hooks play nicely–very nicely–with the rest of our statically-typed application. Still, a couple of caveats apply.

  1. The compiler only knows what it’s told. Hook interfaces are typed, but their types are simple, generic, and minimally prescriptive. What goes on inside a custom hook implementation? That’s our thing.

  2. Hooks expect to run inside React components. This is a non-issue in production, where everything happens inside components, but it does slightly complicate testing.

Testing Custom Hooks

In fact, let’s try testing a custom hook.

const useCapitalized =
  (s: string): ReturnType<typeof React.useState> => {
    const [state, setState] = React.useState(s);
    return [state.toUpperCase(), setState];
  };

We wouldn’t ever use useCapitalized in practice–toUpperCase() works fine, thank you, with no extra boilerplate required–but it’s a useful MacGuffin for now.

In order to test it, we’d like to be able to write something like:

test('sets initial state', () => {
  const [actual] = useCapitalized('Hello, world');
  expect(actual).toEqual('HELLO, WORLD');
}

But this test isn’t quite enough on its own. To make it pass, we’ll need a minimal test component that the hook can run inside. Adding it, our final test will look something like this:

import * as React from 'react';
import * as TestRenderer from 'react-test-renderer';

// ...
const HelloWorld = () => {
  const [state] = useCapitalized('Hello, world');
  return <b>{state}</b>;
};

test('.useCapitalized', () => {
  const testRenderer = TestRenderer.create(<HelloWorld />);
  expect(testRenderer.toJSON()).toMatchSnapshot();
});

Not bad at all, in the grand scheme of test-harness complexity, and so the verdict stands: between easy integration and easy testing, React’s hooks should be good friends to TypeScript developers for many years to come.

Hey, it's RJ—thanks for reading! If you enjoyed this post, would you be willing to share it on Twitter, Facebook, or LinkedIn?