HomeGuidesTypeScript & React

TypeScript and React: Hooks

Stefan Baumgartner

Stefan on Mastodon

More on TypeScript

Hooks have been announced at React Conf 2018. Check out this page for more details. I think they’re pretty awesome. Probably game-changing! Hooks heave formerly “stateless” functional components to … basically everything traditional class components can be. With a much cleaner API!

Just quickly after their release in React 16.7., React typings in DefinitelyTyped got an update as well. Check out how you can use hooks with TypeScript!

Disclaimer: This is all very experimental. Sweet nonetheless.

In this section:

  1. useState
  2. useEffect
  3. useContext
  4. useRef
  5. useMemo and useCallback
  6. useReducer

useState #

useState is probably one you are going to use a lot. Instead of using this.state from class components, you can access the current state of a component instance, and initialise it, with one single function call. Our desire for strong typing is that values we initially set, get per component update, and set through events, always have the same type. With the provided typings, this works without any additional TypeScript:

// import useState next to FunctionComponent
import React, { FunctionComponent, useState } from 'react';

// our components props accept a number for the initial value
const Counter:FunctionComponent<{ initial?: number }> = ({ initial = 0 }) => {
// since we pass a number here, clicks is going to be a number.
// setClicks is a function that accepts either a number or a function returning
// a number
const [clicks, setClicks] = useState(initial);
return <>
<p>Clicks: {clicks}</p>
<button onClick={() => setClicks(clicks+1)}>+</button>
<button onClick={() => setClicks(clicks-1)}>-</button>
</>
}

And that’s it. Your code works with out any extra type annotations, but still typechecks.

useEffect #

useEffect is here for all side effects. Adding event listeners, changing things in the document, fetching data. Everything you would use component lifecycle methods for (componentDidUpdate, componentDidMount, componentWillUnmount) The method signature is pretty straightforward. It accepts two parameters:

  • A function that is called without any parameters. This is the side-effect you want to call.
  • An array of values of type any. This parameter is optional. If you don’t provide it, the function provided is called every time the component update. If you do, React will check if those values did change, and triggers the function only if there’s a difference.
// Standard use case.
const [name, setName] = useState('Stefan');
useEffect(() => {
document.title = `Hello ${name}`;
}, [name])

You don’t need to provide any extra typings. TypeScript will check that the method signature of the function you provide is correct. This function also has a return value (for cleanups). And TypeScript will check that you provide a correct function as well:

useEffect(() => {
const handler = () => {
document.title = window.width;
}
window.addEventListener('resize', handler);

// ⚡️ won't compile
return true;

// ✅ compiles
return () => {
window.removeEventListener('resize', handler);
}
})

This also goes for useLayoutEffect and useMutationEffect.

useContext #

useContext allows you to access context properties from anywhere in your components. Much like the Context.Consumer does in class components. Type inference works brilliantly here, you don’t need to use any TypeScript specific language features to get everything done:

import React, { useContext } from 'react';

// our context sets a property of type string
export const LanguageContext = React.createContext({ lang: 'en' });

const Display = () => {
// lang will be of type string
const { lang } = useContext(LanguageContext);
return <>
<p>Your selected language: {lang}</p>
</>
}

Again, as it should be!

useRef #

useRef is nice as you can set references directly in your function components. However, this was the first time I found hooks together with TypeScript a bit tricky! When you are in strict mode, TypeScript might complain:

import React, { useRef } from 'react';
function TextInputWithFocusButton() {
// it's common to initialise refs with null
const inputEl = useRef(null);
const onButtonClick = () => {
// ⚡️ TypeScript in strict mode will complain here,
// because inputEl can be null!
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}

Here’s what bugs us:

  • usually we initialise refs with null. This is because we set it later in our JSX calls
  • with the initial value of a ref being null, inputEl might be null. TypeScript complains that you should do a strict null check.

That’s not the only thing. Since TypeScript doesn’t know which element we want to refer to, things like current and focus() will also probably be null. So our strict null checks are pretty elaborate. We can make this a ton easier for us and for TypeScript, when we know which type of element we want to ref. This also helps us to not mix up element types in the end:

function TextInputWithFocusButton() {
// initialise with null, but tell TypeScript we are looking for an HTMLInputElement
const inputEl = useRef<HTMLInputElement>(null);
const onButtonClick = () => {
// strict null checks need us to check if inputEl and current exist.
// but once current exists, it is of type HTMLInputElement, thus it
// has the method focus! ✅
if(inputEl && inputEl.current) {
inputEl.current.focus();
}
};
return (
<>
{ /* in addition, inputEl only can be used with input elements. Yay! */ }
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}

A bit more type safety for all of us ❤️

useMemo - useCallback #

You know from useEffect that you can influence the execution of certain functions by passing some parameters to it. React checks if those parameters have changed, and will execute this function only if there’s a difference.

useMemo does something similar. Let’s say you have computation heavy methods, and only want to run them when their parameters change, not every time the component updates. useMemo returns a memoized result, and executes the callback function only when parameters change.

To use that with TypeScript, we want to make sure that the return type from useMemo is the same as the return type from the callback:

/**
* returns the occurence of if each shade of the
* red color component. Needs to browse through every pixel
* of an image for that.
*/

function getHistogram(image: ImageData): number[] {
// details not really necessary for us right now 😎
...
return histogram;
}

function Histogram() {
...
/*
* We don't want to run this method all the time, that's why we save
* the histogram and only update it if imageData (from a state or somewhere)
* changes.
*
* If you provide correct return types for your function or type inference is
* strong enough, your memoized value has the same type.
* In that case, our histogram is an array of numbers
*/

const histogram = useMemo(() => getHistogram(imageData), [imageData]);
}

The React typings are pretty good at that, so you don’t have to do much else.

useCallback is very similar. In fact, it’s a shortcut that can be expressed with useMemo as well. But it returns a callback function, not a value. Typings work similar:

const memoCallback = useCallback((a: number) => {
// doSomething
}, [a])

// ⚡️ Won't compile, as the callback needs a number
memoCallback();

// ✅ compiles
memoCallback(3);

The key here is: Get your typings right. The React typings do the rest.

useReducer #

Now this is something, isn’t it? The core of Redux and similar state management libraries baked into a hook. Sweet and easy to use. The typings are also pretty straightforward, but let’s look at everything step by step. We take the example from the website, and try to make it type safe.

const initialState = { count: 0 };

function reducer(state, action) {
switch (action.type) {
case 'reset':
return initialState;
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}

function Counter({ initialCount = 0}) {
const [state, dispatch] = useReducer(reducer, { count: initialCount });
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</>
);
}

useReducer accepts a reducer function and an initial state. The reducer function switches the action.type property and selects the respective action. Nothing new. It’s just that right now, everything is of type any. We can change that.

The useReducer typings are nice as you don’t have to change anything in the usage of useReducer, but can control everything via type inference from the reducer function. Let’s start by making the actions more type safe. Here’s what we want to avoid:

  • listening to actions that are not reset, increment or decrement
  • Making sure that the type property is set.

For that, we create an ActionType type definition. We use union types to make sure that type can only be of reset, increment or decrement.

type ActionType = {
type: 'reset' | 'decrement' | 'increment'
}

const initialState = { count: 0 };

// We only need to set the type here ...
function reducer(state, action: ActionType) {
switch (action.type) {
// ... to make sure that we don't have any other strings here ...
case 'reset':
return initialState;
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}

function Counter({ initialCount = 0 }) {
const [state, dispatch] = useReducer(reducer, { count: initialCount });
return (
<>
Count: {state.count}
{ /* and can dispatch certain events here */ }
<button onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</>
);
}

That’s not much to do, to make our actions type safe. If you want to add another action, do it at your type declaration. It’s the same with the state. The useReducer typings infer state types from the reducer function:

type StateType = {
count: number
}

function reducer(state: StateType, action: ActionType) {
...
}
function Counter({ initialCount = 0 }) {
// ⚡️ Compile error! Strings are not compatible with numbers
const [state, dispatch] = useReducer(reducer, { count: 'whoops, a string' });
...
// ✅ All good
const [state, dispatch] = useReducer(reducer, { count: initialCount });
...
}

Bottom line #

I think hooks are exciting. I also think that TypeScript’s great generics and type inference features are a perfect match to make your hooks type safe, without doing too much. That’s TypeScript’s greatest strength: Being as little invasive as possible, while getting the most out of it.

Stay up to date!

3-4 updates per month, no tracking, spam-free, hand-crafted. Our newsletter gives you links, updates on fettblog.eu, conference talks, coding soundtracks, and much more.