DEV Community

Matti Bar-Zeev
Matti Bar-Zeev

Posted on • Updated on

Converting a React component to TypeScript

In this post you will join me as I modify a simple component to start utilizing TypeScript.
The WordSearch game which I’m experimenting on was built using CreateReactApp so I will follow their guide on how to enable TS on an existing project.

First we need to install the packages which enable typescript on a project

  • Typescript - the package which enables the actual TS compiler
  • @types/node - the package which contains type definitions for Nodejs
  • @types/react - the package which contains type definitions for React
  • @types/react-dom - the package which contains type definitions for React DOM
  • @types/jest - the package which contains type definitions for Jest

The docs from CreateReactApp tell me to install these as runtime deps, but I think that their place is under the dev deps, so this is where I will install them :)

I’m going to take the AddWord component and convert it to use TS. This component is responsible for adding a new word to the in the words panel for the WordSearch game.

image

Here is the original code which will help you follow through:

import React, {Fragment, useEffect, useRef, useState} from 'react';
import Add from '@material-ui/icons/Add';

const AddWord = ({onWordAdd}) => {
   const inputEl = useRef(null);
   const [newWord, setNewWord] = useState('');
   const [disable, setDisable] = useState(true);

   useEffect(() => {
       // A word is valid if it has more than a single char and has no spaces
       const isInvalidWord = newWord.length < 2 || /\s/.test(newWord);
       setDisable(isInvalidWord);
   }, [newWord]);

   function onAddClicked() {
       onWordAdd && onWordAdd(inputEl.current.value);
       setNewWord('');
   }

   function onChange(e) {
       const value = e.target.value;
       setNewWord(value);
   }

   return (
       <Fragment>
           <input
               type="text"
               name="new"
               required
               pattern="[Bb]anana|[Cc]herry"
               ref={inputEl}
               placeholder="Add word..."
               value={newWord}
               onChange={onChange}
           />
           <button onClick={onAddClicked} disabled={disable}>
               <Add></Add>
           </button>
       </Fragment>
   );
};

export default AddWord;
Enter fullscreen mode Exit fullscreen mode

I start by changing the file extension to .tsx - src/components/AddWord.js > src/components/AddWord.tsx

Launching the app I’m getting my first type error:

TypeScript error in 
word-search-react-game/src/components/AddWord.tsx(4,19):
Binding element 'onWordAdd' implicitly has an 'any' type.  TS7031

   2 | import Add from '@material-ui/icons/Add';
   3 |
 > 4 | const AddWord = ({onWordAdd}) => {
     |                   ^
Enter fullscreen mode Exit fullscreen mode

Let’s fix that.
The problem here is that the component does not declare the type of props it allows to be received. I saw 2 methods of addressing this issue. One is using the React.FC and the other is approaching this function component as a function and therefore regard its typing as a function without React’s dedicated typings. Reading Kent C. Dodds' article about the issue, and also the caveats of using React.FC in this detailed StackOverflow answer, I decided to go with the conventional function typing way.
Ok, so we need to define the props type. I would like to go with Interface instead of a type, coming from an OOP background, I know that working against interfaces is by far much more flexible.
There is a single prop this component receives and it is a callback function, which has a string argument and returns nothing (I like to mark my interfaces with an “I” prefix).
Our props interface looks like this:

interface IAddWordProps {
   onWordAdd: (value: string) => void;
}
Enter fullscreen mode Exit fullscreen mode

And the usage looks like this:

const AddWord = ({onWordAdd}: IAddWordProps) => {
...
Enter fullscreen mode Exit fullscreen mode

That solved that, on to the next error:

TypeScript error in 
word-search-react-game/src/components/AddWord.tsx(20,32):
Object is possibly 'null'.  TS2531

   18 |
   19 |     function onAddClicked() {
 > 20 |         onWordAdd && onWordAdd(inputEl.current.value);
      |    
Enter fullscreen mode Exit fullscreen mode

Which is true, the inputEl can potentially be null, so how do we go about it?
In general, I don't like suppressing errors and warnings. If you decide to use a tool you don’t need to be easy on the “disable rule” configuration of it, so let’s try and really solve this one.
First I would like to set a type to the inputEl ref, and it can be either null or a React.RefObject interface which has a generics type to it. Since we’re dealing with an input element, it would be HTMLInputElement. The inputEl typing looks like this now:

const inputEl: RefObject<HTMLInputElement> | null = useRef(null);
Enter fullscreen mode Exit fullscreen mode

Still, this does not solve our main issue. Let’s continue.
One option to solve this issue is using optional-chaining, which means that we know and prepare our code to gracefully handle null pointers. The handler looks like this now:

function onAddClicked() {
   onWordAdd && onWordAdd(inputEl?.current?.value);
Enter fullscreen mode Exit fullscreen mode

But once we do that we have broken the interface of the props we defined earlier, since it expects to receive a string and now it can also receive undefined, so let’s fix the interface to support that as well:

interface IAddWordProps {
    onWordAdd: (value: string | undefined) => void;
}
Enter fullscreen mode Exit fullscreen mode

Done. On to the next error.

TypeScript error in 
word-search-react-game/src/components/AddWord.tsx(24,23):
Parameter 'e' implicitly has an 'any' type.  TS7006

   22 |     }
   23 |
 > 24 |     function onChange(e) {
      |                       ^
   25 |         const value = e.target.value;
Enter fullscreen mode Exit fullscreen mode

The solution here is simple - I’m adding the ChangeEvent type to e. Now it looks like this:

function onChange(e: ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setNewWord(value);
}
Enter fullscreen mode Exit fullscreen mode

This is not a “React type” and as for now I don’t see any reason to use React types when not needed (if you do know of such a reason, please share in the comments).

And that’s it! The application is back and running :)
Below you can find the modified code (with some additional, non-critical types added) and you can compare it to the original one at the start of this post.

update -
After some great feedback in the comments below (and on Reddit) I've made some modifications in the code accordingly. Thanks guys.

import React, {ChangeEventHandler, MouseEventHandler, RefObject, useRef, useState} from 'react';
import Add from '@material-ui/icons/Add';

interface IAddWordProps {
    onWordAdd?: (value: string | undefined) => void;
}

const AddWord = ({onWordAdd}: IAddWordProps) => {
    const inputEl: RefObject<HTMLInputElement> | null = useRef(null);
    const [newWord, setNewWord] = useState('');
    const [disable, setDisable] = useState(true);

    const onAddClicked: MouseEventHandler<HTMLButtonElement> = () => {
        onWordAdd?.(newWord);
        setNewWord('');
    };

    const onChange: ChangeEventHandler<HTMLInputElement> = ({currentTarget: {value}}) => {
        setNewWord(value);
        // A word is valid if it has more than a single char and has no spaces
        const isInvalidWord: boolean = value.length < 2 || /\s/.test(value);
        setDisable(isInvalidWord);
    };

    return (
        <>
            <input
                type="text"
                name="new"
                required
                pattern="[Bb]anana|[Cc]herry"
                ref={inputEl}
                placeholder="Add word..."
                value={newWord}
                onChange={onChange}
            />
            <button onClick={onAddClicked} disabled={disable}>
                <Add />
            </button>
        </>
    );
};

export default AddWord;
Enter fullscreen mode Exit fullscreen mode

Cheers :)

Hey! If you liked what you've just read be sure to also visit me on twitter :) Follow @mattibarzeev 🍻

Discussion (11)

Collapse
lukeshiru profile image
LUKESHIRU

I have a few suggestions to improve this, I put them directly in code and added comments:

import Add from "@material-ui/icons/Add";
import type { ChangeEventHandler, MouseEventHandler, FC } from "react";
import { useState } from "react";

interface IAddWordProps {
    readonly onWordAdd?: (value: string) => void;
}

// ⚠️ You can use the `FC` type here
export const AddWord: FC<IAddWordProps> = ({ onWordAdd }) => {
    const [newWord, setNewWord] = useState("");

    // ⚠️ You could inline this and skip all the typing
    const onAddClicked: MouseEventHandler<HTMLButtonElement> = () => {
        // ⚠️ You can use `?.` to run this function only when is defined
        // ⚠️ You don't need a ref, you're already tracking the value with `newWord`
        onWordAdd?.(newWord);
        setNewWord("");
    };

    // ⚠️ You could inline this and skip all the typing
    const onChange: ChangeEventHandler<HTMLInputElement> = ({
        currentTarget: { value }
    }) => setNewWord(value);

    // ⚠️ You don't need `<Fragment></Fragment>`, you can use `<></>`
    return (
        <>
            <input
                name="new"
                onChange={onChange}
                pattern="[Bb]anana|[Cc]herry"
                placeholder="Add word..."
                required
                type="text"
                value={newWord}
            />
            <button
                onClick={onAddClicked}
                // ⚠️ You don't need a state for this, you can just put the logic here
                disabled={newWord.length < 2 || /\s/.test(newWord)}
            >
                {/* ⚠️ You can make `Add` self-closing */}
                <Add />
            </button>
        </>
    );
};

export default AddWord;
Enter fullscreen mode Exit fullscreen mode

Cheers!

Collapse
mbarzeev profile image
Matti Bar-Zeev Author

Great code review 👍 thanks!
This this "sandbox" game is being tweaked by me as time goes by I tend to focus on the specific reasons for modifying it, and so some original code can surely use some modern react practices :)

Collapse
constantiner profile image
Konstantin Kovalev

I'd suggest not to use FC< IAddWordProps > (or FunctionComponent<IAddWordProps>) here because of this type supposes component also accepts children (in this case not). Use VoidFunctionComponent<IAddWordProps> (or VFC<IAddWordProps>) instead.

Collapse
lukeshiru profile image
LUKESHIRU

I have a kinda personal rule to only use JSX with components that can take children, if they don't, then they are should be just functions instead, because I like my components to be as flexible and predictable as you were using the underlying native elements directly. I know there are a few native elements such as input that don't take children, but those are just a few exceptions.

Don't get me wrong, you're correct, VFC is a better type if you don't use children, but I personally prefer to always use children if I'm using JSX.

Thread Thread
constantiner profile image
Konstantin Kovalev

But for this particular component even if you pass some children, they will attach nowhere. So passing them is useless. Why not restrict them then? Or I missed the point?

Thread Thread
lukeshiru profile image
LUKESHIRU

For this particular component, children could go just next to the Add icon, like: <Add />{children}, or make it optional, like: {children ?? <Add />}, but yup, as it's being used right now, VFC is a better option (I mainly explained the reason why I personally default to FC).

Collapse
mbarzeev profile image
Matti Bar-Zeev Author

BTW, I'm not using FC on purpose ;)

Collapse
mbarzeev profile image
Matti Bar-Zeev Author

... and another thing - I like to keep my component's state as such, even though the "disabled" can be calculated on render. I find it more convenient if later on I would like to introduce a prop which initialize that state and it is much more readable IMO.

Thread Thread
lukeshiru profile image
LUKESHIRU

If you introduce a new prop, that prop can be added to the logic of disabled. If you want to keep it readable you can have it in a const instead of having it inline. What's important is to avoid using state when you don't need it, and in this case disabled can be inferred from other state+props, so no need for a state for it and a reducer just to keep that in sync. I mean you have:

// This
const Example = ({ logic }) => {
    const [disable, setDisable] = useState(true);
    useEffect(() => setDisable(logic), [logic]);
    return <input disabled={disable} />;
};

// vs this:
const Example = ({ logic }) => {
    return <input disabled={logic} />;
};
Enter fullscreen mode Exit fullscreen mode
Collapse
jbartusiak profile image
Jakub Bartusiak • Edited on

Good walkthrough. The point of using react events is to guarantee that they get handled the same way, no matter which browser the app is running it - but agreed that for the demo here that's not really crucial. While you're typing your event to a change event, under the hood you're still using the synthetic event. The interface is the same, that's why you don't see any errors.

Collapse
mbarzeev profile image
Matti Bar-Zeev Author

Thanks for the informative feedback! I am still not quite sure, bit something tells me that if we can avoid additional abstractions, we should. So in general, if the "plain" interface is applicable, I don't a reason to use the abstraction.