Incrementally adopting TypeScript in a create-react-app project
Manny Colon
Posted on January 11, 2022
You can gradually adopt TypeScript in your create-react-app project. You can continue using your existing Javascript files and add as many new TypeScript files as you need. By starting small and incrementally converting JS files to TypeScript files, you can prevent derailing feature work by avoiding a complete rewrite.
Incrementally adopting TypeScript in a create-react-app project can be valuable, especially if you don't want to do a full-fledged migration before you fully learn TypeScript or become more proficient with it.
For this tutorial, the app we'll be converting to TypeScript is a counter app built with redux-toolkit
, if you're not familiar with redux, redux-toolkit or TypeScript, I highly suggest you take a look at their docs before doing this tutorial as I assume you have some basic understanding of all of them.
Before you start please make sure you don't have create-react-app
globally installed since they no longer support the global installation of Create React App.
Please remove any global installs with one of the following commands:
- npm uninstall -g create-react-app
- yarn global remove create-react-app
First, let's bootstrap a React app with Create React App, using the Redux and Redux Toolkit template.
npx create-react-app refactoring-create-react-app-to-typescript --template redux
Here is a visual representation of the project's directory and file structure.
π¦ refactoring-create-react-app-to-typescript
β£ π node_modules
β£ π public
β£ π src
β β£ π app
β β β π store.js
β β£ π features
β β β π counter
β β β β£ π Counter.module.css
β β β β£ π Counter.js
β β β β£ π counterAPI.js
β β β β£ π counterSlice.spec.js
β β β β π counterSlice.js
β β£ π App.css
β β£ π App.test.js
β β£ π App.js
β β£ π index.css
β β£ π index.js
β β£ π logo.svg
β β£ π serviceWorker.js
β β π setupTests.js
β£ π .gitignore
β£ π package-lock.json
β£ π package.json
β π README.md
Also, feel free to take a look at the final version of the project here, if you want to see the original Javascript version go here.
Adding TypeScript to create-react-app project
Note: this feature is available with react-scripts@2.1.0 and higher.
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
Installation
To add TypeScript to an existing Create React App project, first install it:
npm install --save typescript @types/node @types/react @types/react-dom @types/jest
# or
yarn add typescript @types/node @types/react @types/react-dom @types/jest
Now, let's start by renaming the index and App files to be a TypeScript file (e.g. src/index.js
to src/index.tsx
and App.js
to App.tsx
) and create a tsconfig.json
file in the root folder.
Note: For React component files (JSX) we'll use
.tsx
to maintain JSX support and for non React files we'll use the.ts
file extension. However, if you want you could still use.ts
file extension for React components without any problem.
Create tsconfig.json
with the following content:
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
Next, restart your development server!
npm start
# or
yarn start
When you compile src/App.tsx
, you will see the following error:
Solution with custom.d.ts
At the root of your project create custom.d.ts
with the following content:
declare module '*.svg' {
const content: string;
export default content;
}
Here we declare a new module for SVGs by specifying any import that ends in .svg
and defining the module's content as string. By defining the type as string we are more explicit about it being a URL. The same concept applies to other assets including CSS, SCSS, JSON, and more.
See more in Webpack's documentation on Importing Other Assets.
Then, add custom.d.ts
to tsconfig.json
.
{
...,
"include": ["src", "custom.d.ts"]
}
Restart your development server.
npm start
# or
yarn start
You should have no errors and the app should work as expected. We have converted two files (Index.js -> index.tsx and App.js -> App.tsx) to TypeScript without losing any app functionality. Thus, we've gained type checking in our two converted files.
Now, we can incrementally adopt TypeScript in our project one file at a time. Let's do exactly that, starting with Counter.js
. Change Counter.js
to Counter.tsx
.
Restart the app, npm start
or yarn start
.
It will complain that it cannot find module ./Counter.module.css
or its corresponding type declarations.
We can fix it by adding a type declaration for *.module.css
to the end of custom.d.ts
. So, our custom.d.ts
file should look as follow:
custom.d.ts
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.module.css';
Alternatively, you could also use typescript-plugin-css-modules to address the CSS modules error but adding a type declaration is good enough in this case.
The next error/warning is related to incrementAsync
.
However, before we fix the second error in counterSlice.tsx
, we must change src/app/store.js
to src/app/store.ts
then define Root State and Dispatch Types by inferring these types from the store itself which means that they correctly update as you add more state slices or modify the middleware setting. Read more about using TypeScript with Redux in their TypeScript docs.
src/app/store.ts
should look as follows.
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
Okay, now that we have defined Root State and Dispatch Types let's convert counterSlice
to TypeScript.
src/features/counter/counterSlice.js
-> src/features/counter/counterSlice.ts
In counterSlice.ts
the first error is that the type of the argument for payload creation callback is missing. For basic usage, this is the only type you need to provide for createAsyncThunk
. We should also ensure that the return value of the callback is typed correctly.
The incrementAsync
function should look like this:
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
'counter/fetchCount',
// Declare the type your function argument here:
async (amount: number) => {// HERE
const response = await fetchCount(amount);
// The value we return becomes the `fulfilled` action payload
return response.data;
}
);
We added a type (number
) to the argument called amount
in the callback function passed to createAsyncThunk
as the second argument.
Before we go on with the other type errors, we must address the error with the response value returned from the fetchCount
function inside the function callback passed to createAsyncThunk
in incrementAsync
. In order to fix it we must first fix it at the root of the problem, inside counterAPI.js
.
Thus, first convert counterAPI.js
to counterAPI.ts
.
type CountType = {
data: number;
};
// A mock function to mimic making an async request for data
export function fetchCount(amount: number = 1) {
return new Promise<CountType>((resolve) =>
setTimeout(() => resolve({ data: amount }), 500)
);
}
In this Promise, I have used the promise constructor to take in CountType as the generic type for the Promiseβs resolve value.
Now, let's go back to counterSlice.ts
and the next error is that the selectCount
selector is missing a type for its argument. So, let's import the types we just created in store.ts
.
Import RootState
and AppDispatch
types:
import type { RootState, AppDispatch } from '../../app/store'
Use RootState
as a type for selectCount
's argument (state)
selectCount
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value;
incrementIfOdd
// We can also write thunks by hand, which may contain both sync and async logic.
// Here's an example of conditionally dispatching actions based on the current state.
export const incrementIfOdd =
(amount: number) => (dispatch: AppDispatch, getState: () => RootState) => {
const currentValue = selectCount(getState());
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount));
}
};
Okay, we should have zero type errors or warnings now. We've converted the following files to TypeScript:
src/app/store.ts
src/features/counter/Counter.tsx
src/features/counter/counterSlice.ts
src/features/counter/counterAPI.ts
Finally, let's convert our test files:
Change App.test.js
to App.test.tsx
and counterSlice.spec.js
to counterSlice.spec.ts
Run your tests:
npm test
or
yarn test
All tests should pass, however, you may encounter the following problem:
"Property 'toBeInTheDocument' does not exist on type 'Matchers<any>'."
To fix it, you can try adding the following to tsconfig.json
:
...,
"exclude": [
"**/*.test.ts"
]
All tests should pass now:
Feel free to check out my repo with the final version of this app.
Thanks for following along, happy coding!
Posted on January 11, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
April 11, 2023