Using React Hooks with TypeScript
- 1/27/2019
- ·
- #typescript
- #react
- #howto
React and TypeScript were always a powerful combination for building safer, more scalable web applications, and hooks are making them even better.
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.
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.
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.
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.