Adding types to native React Hooks. All about including TypeScript in a React Project.

Michal Majer

Jun 23 / 2021

We’ve built a simple application with a global store managed with Redux library, and then we changed it to use built-in React Hooks. Based on this Premier League fixtures application, we are going to provide type checks for our functionality with TypeScript. TS is an open-source programming language from Microsoft. It helps us prevent possible code issues whenever we do not provide – or provide wrong – unexpected data to our components or functionality. In simple words, it gives you the ability to add static types to your Javascript code.
This post will show you how to include TypeScript in an already started project. Of course, I always recommend using it from the start as it provides many great developer features.

Environment

As seen in previous examples of plain Redux and hooks, there are many appearances of data that are not properly checked before it’s rendered. You can still trace those issues if you have a good and configured IDE which may prompt you about a possible bug. Anyway, we are allowed to render e.g. image like that:

process.env.REACT_APP_API_KEY

By now, you can probably tell what I mean. We are not sure if the REACT_APP_API_KEY property exists in env variables, so we can face an error from JS and break the app if it’s missing. We have to define in our code if this value is required (so we can safely expect it anytime), or if it’s optional (and what to do in case it’s absent).

To get started, you have to install typescript package along with types supporting packages for some other external libraries like e.g. @types/react to use with React, or… you can use CRA as before. As you remember, we’re going to do the first command to install Create React App the same way, but with one additional parameter:

npx create-react-app my-app –template typescript

This will cover and manage for you all required TypeScript functionality, dependencies, and configuration which you can always modify for your own purposes via tsconfig.json file.

Now you can start writing your app, but in our case, we will just copy src folder from the last hooks implementation. You can choose your own way and for better experience start writing it from scratch.

//TODO: URL

 

Features

If your app is already built on PropTypes, you would set those properties as required or make them default valued, and that can keep you informed in devtools if you are “doing a good job”. Using PropTypes you may be interested in inferring your types using InferProps from @types/prop-types package.

We will provide types to our entire app using only TypeScript. The main difference between Typescript and PropTypes is that Typescript validates types at compile-time, while PropTypes are checked at runtime. So TypeScript is a bit more than just forcing types on component props, but wherever (states, events, plain variables) we want our JavaScript to represent specific type/types structure. It will avoid us to run a project if any issues with types occur.

TS has also a great feature hidden under CTRL + SPACE shortcut for code completion based on our types. Imagine we created types for team object and then after calling team. We can use a shortcut for all possible values we may expect:

TypeScript is always recommended to be written simultaneously with the rest of the code to better control your application. Always consider using TS when you are starting a project. It’s often difficult to convert the existing code and you may face some code restructuring.

The biggest challenge is to find a proper place to include your types or interfaces. As you switch your files to .ts extension and place them in a TS-supported environment, you will face many errors. When trying to provide types to specific files it can turn out that your types should be somewhere higher in the project tree to avoid redundant types in other places. That’s why it’s always good to start from the highest to lowest file in your components hierarchy.

In our case, it’s best to start from our store, as it is wrapping the whole app and providing it with some data.

Files extensions

Let’s switch all our *.js and *.jsx files into .tsx and .ts files. It depends on the file. The .tsx format can be used to parse JSX content type, so it’s used for React code. Actually, it’s a requirement to use TSX when writing JSX code. If you don’t use TypeScript, you can keep your JSX React code in plain .js files. Plain JS functionalities like our services should use .ts extension.

 

TypeScript for React context and useReducer hook

Now, let’s go to our index file of store folder and make following changes:

type TypeAppContext {
    state: TypeState,
    dispatch: (e: Action) => void,
}

export const AppContext = React.createContext<Partial<TypeAppContext>>({});

Instead of creating context to store our global state values, we can define expected appearance of this data. In our case, we called it TypeAppContext.

Please also note the use of Partial<> element of TypeScript. It allows us to use an empty initial value when the app is rendered for the first time when we didn’t provide any data to context yet. In that case, you will have to use a conditional for every context value used around the app to try to set some default ones to be sure they will always be consumed by components.

To handle that, remove Partial and add some initial state:

Default values as better user experience

dispatch(setCurrentTeam(team.id))

This will make TS yelling about dispatch usage (as it doesn’t exist in value of initial create context), so we have to make sure we trigger it only when available:

dispatch?.(setCurrentTeam(team.id))
dispatch && dispatch(setCurrentTeam(team.id)) 
if (!!dispatch)
     dispatch(setCurrentTeam(team.id))

Or whichever method you prefer. By providing an initial value (example below), we can omit these conditions (so the first example will not cause any errors like before) and in case of any problems display some basic alert to our user that something was wrong, but nothing lost yet and still can retry 😉

It will highly improve our application UX:

export const initialState: TypeAppContext = {
    state: {
        teams: [],
        current: 0
    },
    dispatch: () => alert('Context "dispatch()" function has not been loaded yet, please try again later.')
};

export const AppContext = React.createContext<TypeAppContext>(initialState);

state elements can be left empty as we need it to reflect the component statuses, like displaying loadings, errors, etc. We also want to export this value to use in reducers initial states later. For the dispatch function without any default value, you will be forced to trigger it conditionally, like in the above examples. As we mentioned, TypeScript is checking functionality at compile-time. While it is undefined at first render, it will stop us from doing so unless we use some conditional to be sure it exists and we can safely trigger it.

Flexible types

We know we are passing two values to make our component able to communicate and contribute to our store. Those values are state with app data, and dispatch function to fire assigned actions. Dispatch as you may have noticed is defined as a function returning nothing (void) but taking an Action type as a parameter. This parameter is an object of action type and some payload depends on which one we are dispatching. So for now, let’s create a type that can be one of our actions types, but we will talk about later:

export type Action = ActionAddTeams | ActionAddStats | ActionSetCurrent;

…and add type for the second context param as follows:

type TypeState = {
    teams: TypeTeam[],
    current: number
}

In store state, we are collecting two values as above, so we need also types for them. Current will be just a number corresponding to a chosen team ID, and teams suppose to be an array of received teams from an API. We can type them to look like this.

export type TypeTeam {
    id: number,
    name: string,
    crestUrl: string,
    shortName: string,
    stats?: TypeStat[]
}

You can use a smart tip to handle them. To remind, you we are using football-data.org, but whatever service you use, you can take a look at the API structure and get values you need to render components. There are also great online converters for TypeScript, so you can make a test request to your API endpoint, copy all JSON responses and paste it to one of them, eg. https://app.quicktype.io. Then, you can take retrieved Types into your code and adjust if needed.

In TypeTeam there is one custom and optional key called stats. This is where we are collecting statistics for a current ‘team’ after it was clicked for later use. In the first save of the ‘team’ object, this is hasn’t occurred yet, that’s why it’s set as optional. So we can actually define it as well somewhere else:

export type TypeStat {
     id: number,
     competition: {
         name: string
     }
     score: {
         fullTime: {
             homeTeam: number
             awayTeam: number
         }
     }
     awayTeam: {
         name: string
     }
     homeTeam: {
         name: string
     }
 }

It’s good to export them already as we expect to use them later in components.

Typing through store

Now that we have all context and store values handled, we only need to add types to store Provider itself. We’ll use the same initial value for useReducer hook as for context. For rect functional components it’s most common to use React.FC<> which takes an component props types:

type TypeAppStore = {
    children: ReactNode
}

export const AppStore: React.FC<TypeAppStore> = ({children}) => {

    const [state, dispatch] = useReducer(combineReducers({
        teams: teamsReducer,
        current: currentReducer
    }), initialState);

    return [...]
}

ReactNode is an element that suits best to all children that we want to pass via the children prop. You can play with other possible ones as there are some, but they are too specific and cannot handle all our purposes.

Now lets go a bit deeper into our actions and reducers. First of all we should type our static variables and the only ones we have are the actions names saved to constants. It’s a common Redux practice, but as for now, we have to use them not only as a type property of a reduced action, but also in TypeScript to check the current type.

We could define just a string type for type property and it would be true, but then we allow other developers to trigger actions that are not registered. To communicate our code and local environment what kind of actions we are able to use, we can use enum. About enum and all other Typescript features, you can read in their documentation, but as a TL;DR, I can quote the most important part: Enums allow us to define a set of named constants. Here is how they look in our application:

export enum Actions {
    ADD_TEAM_STATS = 'ADD_TEAM_STATS',
    ADD_TEAMS = 'ADD_TEAMS',
    SET_CURRENT_TEAM = 'SET_CURRENT_TEAM',
}

Now, we can use their values as normal variables. Treat it like plain JS object :

export const addTeams = teams => ({
    type: Actions.ADD_TEAMS,
    teams,
});

Continuing typescripting actions we have to define what type of prop it receives and what type it returns:

export type ActionAddTeams = {
    type: Actions.ADD_TEAMS,
    teams: TypeTeam[]
}
export const addTeams = (teams: TypeTeam[]): ActionAddTeams => ({
    type: Actions.ADD_TEAMS,
    teams,
});

So we need to define a type for this action. This type is one of our Action types which is a param for the reducer function (and as you can see it’s just a plain object with the action of Actions.ADD_TEAMS and an array of before typed team TypeTeam[]). As a reminder:

// dispatch function type:
dispatch: (e: Action) => void;

// Action type as a param for dispatch function:
type Action = ActionAddTeams | ActionAddStats | ActionSetCurrent;

With that, we can be 100% sure addTeams will never be used incorrectly or with wrong data values as your project will just stop you or any other developer who wants to use it.

Accordingly, we can deliver other types like ActionAddStats and ActionSetCurrent for addTeamStats and setCurrentTeam functions(actions).

Then let’s go to reducers and make sure they are using proper action types when triggered. To do that, we have to switch all case statements from before plain constants to new enum object so:

case Actions.ADD_TEAMS:
    return [...state, ...action.teams];

instead

case ADD_TEAMS:
    return [...state, ...action.teams];

Just make sure you imported proper Actions object from where you created that. I put it into the main store index file as it is sharable content through all store files.

This is a place where the aforementioned glitch (of switching app to TypeScript instead of providing it from with project from start) is perfectly visible. When we open the reducers.ts file to make it at least renderable, first we need to switch the actions types as they are declared differently now. But when you start writing your code with Typescript from scratch, you can enjoy all its features like this one:

export const teamsReducer = (state = initialState.state.teams, action: Action): TypeTeam[] => {...}

This is our reducer definition with types. As every reducer, it takes state and action objects and returns a new state. So in the reducer responsible for teams part of the application, we will have a state with initial data as imported from the context default state. As we are passing here a specific value, the Typescript will use its type as the variable we are assigning to. Your IDE should show you this information:

Then, as a second action parameter, we use also an already defined Action type which resolves to one of our action registered. The only thing you should remember is to keep up to date the types describing them whenever you add a new one:

type Action = ActionAddTeams | ...;

End then we can tell TypeScript that this reducer will return an array of TypeTeam objects which are part of our store along with current from our second reducer which is actually desired case.

Whenever you start typing from that side, then you can really fast and easy build your reducer statements:

Just pick your desired one, hit enter, and finish with your custom functionality.

Now we can do the same for all current actions and reducers (which is literally one :D), but I would omit that to not extend the content of this article unnecessarily. You can always see the whole project at the end of the post in the codesandbox.io environment.

How React components react to typed data

We have finished typing all our global store functionality from simple actions through the whole context and useReducer. Time to use them in components and make them properly typed as well.

Let’s start with the first component which uses store which is Teams. You and your co-developers do not need to look for possible values at various files anymore. You can see them live while typing as well as expected types for them and quickly use to save some development time:

The Teams component itself does not provide any props any so we don’t have any Types there to register.

We have to just adjust the code a little to make it work properly with our store data, but your IDE (with a little help of eslint which I strongly recommend using to improve good practices and formats for your code) should help you with it easily trace the breaking parts.

First of all, we have to change conditionals for teams property as it is an empty array at its initial state now. We’ve been quite forced to do it, to keep our data flow safer and more consistent. So now we need to check if the property is not empty, instead if it just exists as some value, and we have to do it for every place we checked the teams so:

in UseEffect:

// Return if data exists in store or is already requesting for it
if ((!!teams?.length) || status === 'success') {
return;
}

and in loop rendering single Team:

{!!teams?.length && (
<div className="Team__list">
    {teams.map(team => (
        <Team key={team.id}
              team={team}
        />
    ))}
</div>
)}

Please also note the ?. appearance, as an optional chaining operator. Typescript also wants us to be sure that team variable exists before we ask for its length, so this operator can help us to determine it.

As an addition and to keep all thing consistent, we can add types to each of our components functionalities, so for a simple state like here:

const [status, setStatus] = useState<string>(defaultStatus);

We can precise that state has to be a string. And for this purpose, I think is best to do the same as we make for store actions to limit possibilities and be sure that we have only expected values. So you can define another type for them or just hardcode like this:

const [status, setStatus] = useState<'idle' | 'loading' | 'success'>(defaultStatus);

We won’t define Types for combineReducers() function or Loading component, as they are just middleware helpers, but you can try it on your own as a good exercise for learning TypeScript. We can just enforce some proper data flying through our apiConnection() helper service:

export const apiConnection = async (endpoint: string = ""): Promise<any> => {
    if (!process.env.REACT_APP_API_KEY) {
        throw Error('environment variable "REACT_APP_API_KEY" is not provided')
    }

    [...]
}

All you have to do after switching the file to .ts extension is to make sure an endpoint is passed as string.

But if you are perceptive, you may have already noticed that types auto-completion from TypeScript will add string type automatically if we assign a string typed value as a default initial one, so this part is not required.

Then, we can make sure our function will return a Promise which from the above code is representing the Type element. As a Promise value we will use any because the server responses are way more complicated than we use, and we actually do not need to care about all of them, so we can keep focus only on if it is Promise to make our element properly consume data providing by it (we will be sure in there that we have a Promise under that variable and can use .then() for example to read it) and make other types checking depends on which endpoint they triggered.

Then TypeScript forces us to make sure that variable process.env.REACT_APP_API_KEY exists before we can use it. So to prevent further problems in case of missing key, we can use a simple conditional and throw an error whenever it happens, while the application couldn’t work without it.

Please have in mind that you’d like to show it to the users with the best UX possible 🙂

This should be fairly enough to cover the API connection functionality as we are the most interested in the data it takes and returns. Since we have all store functionality, API connection, and Teams component accurately typed, we can finish the rest by doing quite the same:

The Team only consume data, so to be sure we passing correct data let’s just type our prop to be consistent:

For Statistics it would be good to provide our local state types:

const [currentTeam, setCurrentTeam] = useState<TypeTeam | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle');

The initial state for currentTeam is null as it waits for user clicks one of the teams, so we can use OR operator from TS to load here just null or TypeTeam registered before.

You may have noticed the same type for status state as we had on Teams component. In case several components use similar or event the same states is good to extract Types to external file. Of course, at first, you have to consider extracting functionality itself, as it may be a good place to use a custom hook and avoid redundant code. One of the good practices is to create types.ts file in the root of your project with all shared types.

If your code follows mine, I can bet you already know what you have to do differently for Statistics. If not, just compile your code and let TypeScript tell you what you have to care about to make your component and code safe 😉

Anyway, we have to do the same thing as for Teams and make sure we are properly reading the teams variable like eg. in the first effect:

const found = (!!teams?.length && current) &&
    teams.find(team => team.id === current);

or down there in rendered component:

{!teams?.length &&
     <Loading message={'Waiting for teams load'} />}

TypeScript is restricted in every place you defined it to be, so it will also tell you to make sure if currentTeam exists before you will ask for its name property, because as you remember we defined it to be null at a default state. I can recommend one of my favorite and the fastest ways to do that checking as I’m using it everywhere:

{status === 'loading' &&
     <Loading message={`Downloading ${currentTeam?.name} data`} />}

In most cases, it would be better to include the currentTeam check in the first conditional to prevent render the entire Loading. In this case thanks to our code structure we are sure that loading status is set only when currentTeam exists.

Please do not forget you can always use, play and test at example environments as promised I created for you on codesandbox, or clone the repo and play with them at your local:

BONUS: As we discuss code and variables safety it is also good to remember to prevent context usage out of Provider. It’s a developer issue but whenever you work in team, it would be much helpful to provide some checker whether you trying to use it in place you can do so (inside Provider) or not.

To handle this case I would recommend you to create your own custom hook to trigger context imports and place it in services folder:

function useContextData() {
    const context = useContext(AppContext);
    if (context === undefined) {
        throw new Error('AppContext must be used within a AppContext.Provider')
    }
    return context
}

Now, you have to replace every occurrence of your useContext(AppContext) usage to just useContextData() and you can be sure that context has no way to be used outside of its Provider and as an additional advantage you will not have to import AppContext anymore in each of those components while useContextData will be enough and will do all things for you.

Conclusion

TypeScript leverage makes our properties more discoverable and consistent. It’s not restricted to any specific area, like hooks or functions. You can add it to any JS statement and take advantage of typing plain variables etc. However, t’s not the only advantage of using TS. Thanks to great code completion supporting you can save plenty of time since you started your project already with TypeScript supported.