loading...

Notes on TypeScript: React Hooks

busypeoples profile image A. Sharif ・7 min read

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples are based on TypeScript 3.2.

React Hooks

In this part of the "Notes on TypeScript" series we will take a look at how React Hooks can be typed with TypeScript and along the way learn more about Hooks in general.

We will be consulting the official React documentation on hooks and is a very valuable source when needing to learn more about Hooks or needing specific answers to specific problems.
In general hooks have been added to React in 16.8 and enable developers to use state inside function components, which was only possible in class components up to that point. The documentation states that there are basic and additional hooks.
Basic hooks are useState, useEffect, useContext and additional hooks include useReducer, useCallback, useMemo, useRef.

useState

Let's begin with useState, a basic hook, that as the name implies should be used for state handling.

const [state, setState] = useState(initialState);

Looking at the above example we can see that useState returns a state value as well as a function to update it. But how we do type state and setState?
Interestingly TypeScript can infer the types, that means by defining an initialState, the types are inferred for both the state value as well as the update function.

const [state, setState] = useState(0);
// const state: number
const [state, setState] = useState("one");
// const state: string
const [state, setState] = useState({
  id: 1,
  name: "Test User"
});
/*
  const state: {
    id: number;
    name: string;
  }
*/
const [state, setState] = useState([1, 2, 3, 4]);
// const state: number[]

The above examples demonstrate quite well, that we don't need to do any manual typing. But what if we don't have an initial state? The above examples would break when trying to update the state.
We can define the types manually when needed, using useState.

const [state, setState] = useState<number | null>(null);
// const state: number | null
const [state, setState] = useState<{id: number, name: string} | null>(null);
// const state: {id: number; name: string;} | null
const [state, setState] = useState<number | undefined>(undefined);
// const state: number | null

It might be also interesting to note, that opposed to setState in class components, using the update hook function requires to return the complete state.

const [state, setState] = useState({
  id: 1,
  name: "Test User"
});
/*
  const state: {
    id: number;
    name: string;
  }
*/

setState({name: "New Test User Name"}); // Error! Property 'id' is missing
setState(state => {
  return {...state, name: "New Test User Name"}
}); // Works!

Another interesting thing to note, is that we can lazily initiate the state via passing a function to useState.

const [state, setState] = useState(() => {
  props.init + 1;
});

// const state: number

Again, TypeScript can infer the state type.

This means we don't need to do very much work when working with useState, only in cases where we don't have an initial state, as the actual state shape might be computed when initially rendering.

useEffect

Another basic hook is useEffect, which is useful when working with side effects, like logging, mutations or subscribing to event listeners. In general useEffect expects a functions that runs an effect which can optionally return a clean up function, which is useful for unsubscribing and removing listeners f.e. Additionally useEffect can be provided with a second argument, containing an array of values, ensuring the effect function only runs when one of these values has changed. This ensures that we can control when a effect is run.

useEffect(
  () => {
    const subscription = props.source.subscribe();
    return () => {
      subscription.unsubscribe();
    };
  },
  [props.source]
);

Taking the original example from the documentation, we can notice that we don't need any extra typings when using useEffect.
TypeScript will complain when we try to return something that is not a function or an undefined inside the effect function.

useEffect(
  () => {
    subscribe();
    return null; // Error! Type 'null' is not assignable to void | (() => void)
  }
);

This also works with useLayoutEffect, which only differs regarding when the effects are run.

useContext

useContext expects a context object and returns the value for the provided context. A re-render is triggered when the provider updates the context. Taking a look at the following example should clarify:

const ColorContext = React.createContext({ color: "green" });

const Welcome = () => {
  const { color } = useContext(ColorContext);
  return <div style={{ color }}>Welcome</div>;
};

Again, we don't need to do very much regarding the types. The types are inferred.

const ColorContext = React.createContext({ color: "green" });
const { color } = useContext(ColorContext);
// const color: string
const UserContext = React.createContext({ id: 1, name: "Test User" });
const { id, name } = useContext(UserContext);
// const id: number
// const name: string

useReducer

Sometimes we are dealing with more complex states, that might depend on the previous state as well. useReducer accepts a function that calculates a specific state depending on the previous state and an action. The following example is taken from the official documentation.

const [state, dispatch] = useReducer(reducer, initialArg, init);

If we look at the example in the documentation, we notice that we will need to do some additional typing work. Check the slightly adapted example:

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

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

Currently state can't be inferred correctly. But we can change this, by adding types for the reducer function. By defining state and action inside the reducer function, we can now infer the state provided by useReducer. Let's adapt the example.

type ActionType = {
  type: 'increment' | 'decrement';
};
type State = { count: number };
function reducer(state: State, action: ActionType) {
  // ...
}

Now we can ensure that the types are inferred inside Counter:

function Counter({initialState = 0}) {
  const [state, dispatch] = useReducer(reducer, initialState);
  // const state = State
  // ...
}

When trying to dispatch a non existent type, we will be greeted with an error.

dispatch({type: 'increment'}); // Works!
dispatch({type: 'reset'});
// Error! type '"reset"' is not assignable to type '"increment" | "decrement"'

useReducer can also be lazily initialized when needed, as sometimes the initial state might has to be calculated first:

function init(initialCount) {
  return {count: initialCount};
}

function Counter({ initialCount = 0 }) {
  const [state, dispatch] = useReducer(red, initialCount, init);
  // const state: State
  // ...
}

As can be seen in the above example, types are inferred with a lazily initialized useReducer due to the correctly typed reducer function.

There is not much more we need to know regarding useReducer.

useCallback

Sometimes we need to memoize callbacks. useCallback accepts an inline callback and an array of inputs for updating the memoization only when one of these values has changed. Let's take a look at an example:

const add = (a: number, b: number) => a + b;
const memoizedCallback = useCallback(
  (a) => {
    add(a, b);
  },
  [b]
);

Interestingly, we can call memoizedCallback with any type and will not see TypeScript complain:

memoizedCallback("ok!"); // Works!
memoizedCallback(1); // Works!

In this specific case, memoizedCallback works with strings or numbers although the add function expects two numbers. To fix this, we need to be more specific when writing the inline function.

const memoizedCallback = useCallback(
  (a: number) => {
    add(a, b);
  },
  [b]
);

Now, we need to pass a number or else the compiler will complain.

memoizedCallback("ok");
// Error! Argument of type '"ok"' is not assignable to argument of type 'number'
memoizedCallback(1); // Works!

useMemo

useMemo is very similar to useCallback, but returns a memoized value instead of a memoized callback. The following is from the documentation.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

So, if we build an example based on the above, we notice that we don't to need to do anything regarding types:


function calculate(a: number): number {
  // do some calculations here...
}

function runCalculate() {
  const calculatedValue =  useMemo(() => calculate(a), [a]);
  // const calculatedValue : number
}

useRef

Finally, we will look at one more hook: useRef.
When using useRef we gain access to a mutable reference object. Further, we can pass an initial value to useRef, which is used to initialized a current property exposed by the mutable ref object. This is useful when trying to access some components inside a function f.e. Again, let's use the example from the documentation.

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus(); // Error! Object is possibly 'null'
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

We can see that TypeScript is complaining, because we initialized useRef with null, which is a valid case, as sometimes set the reference might happen at a later point in time.
This means, we need to be more explicit when using useRef.

function TextInputWithFocusButton() {
  const inputEl = useRef<HTMLInputElement>(null);
  const onButtonClick = () => {
    inputEl.current.focus(); // Error! Object is possibly 'null'
  };
  // ...
}

Being more specific when using useRef via defining the actual type useRef<HTMLInputElement> still doesn't remove the error. Actually checking if the current property exists, will prevent the compiler from complaining.

function TextInputWithFocusButton() {
  const inputEl = useRef<HTMLInputElement>(null);
  const onButtonClick = () => {
    if (inputEl.current) {
      inputEl.current.focus(); // Works!
    }
  };
  // ...
}

useRef can also be used as an instance variable.
If we need to be able to update the current property, we need to use useRef with the generic type Type | null:

function sleep() {
  const timeoutRefId = useRef<number | null>();

  useEffect(() => {
    const id = setTimeout(() => {
      // ...
    });
    if (timeoutRefId.current) {
      timeoutRefId.current = id;
    }
    return () => {
      if (timeoutRefId.current) {
        clearTimeout(timeoutRefId.current);
      }
    };
  });
  // ...
}

There are a couple of more interesting things to learn about React hooks, but are not TypeScript specific. If there is more interest in the topic, consult the official React documentation on hooks.
We should have a good understanding of how to type React Hooks at this point.

If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Posted on by:

busypeoples profile

A. Sharif

@busypeoples

Focusing on quality. Software Development. Product Management. https://twitter.com/sharifsbeat

Discussion

pic
Editor guide