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.
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;
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}) => {
| ^
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;
}
And the usage looks like this:
const AddWord = ({onWordAdd}: IAddWordProps) => {
...
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);
|
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);
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);
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;
}
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;
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);
}
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;
Cheers :)
Hey! If you liked what you've just read be sure to also visit me on twitter :) Follow @mattibarzeev 🍻
Top comments (7)
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 :)
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.
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.
I'd suggest not to use
FC< IAddWordProps >
(orFunctionComponent<IAddWordProps>
) here because of this type supposes component also acceptschildren
(in this case not). UseVoidFunctionComponent<IAddWordProps>
(orVFC<IAddWordProps>
) instead.BTW, I'm not using FC on purpose ;)
... 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.
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?