Foreword: React and TypeScript
This training only covers modern React applications using functional components.
TypeScript and JSX
When it comes to typing JSX elements, TypeScript and React have a deal:
- For intrinsic elements (ex: <div>), React must provide props typings for all elements in a JSX.IntrinsicElements interface (see here).
All intrinsic elements must start with a lowercase letter.
is an intrinsic element which types are provided by React.
- For custom components (cf: value-based elements), TypeScript is responsible for trying to "guess" whenever this element is a function or a class and infer its props types.
In a JSX context, TS will infer Button as a function along with its prop typings.
Typing functions
Typing functions in TypeScript is achieved as follows:
1// functional component
2function MyComponent(props: MyComponentProps): JSX.Element { /* ... */ }
3
4// functional component (as a anonymous function)
5const MyComponent = (props: MyComponentProps): JSX.Element => { /* ... */ }
6
- The function argument has type suffix: : MyComponentProps
- The function signature also has a type suffix: : JSX.Element
Typing the <Button /> component
We already defined our <Button /> props types are as follows
1interface ThemableComponentProps {
2 type: "primary" | "secondary";
3 size: "small" | "medium" | "large";
4 classNames: string[];
5}
6
7interface FormComponentProps {
8 name: string;
9 label?: string;
10 disabled?: boolean;
11}
12
13interface ButtonProps extends ThemableComponentProps, FormComponentProps {
14 loading?: boolean; // adding a loading state to `<Button />`
15}
16
Let's now assign it to our <Button /> component:
1interface ThemableComponentProps {
2 type: "primary" | "secondary";
3 size: "small" | "medium" | "large";
4 classNames: string[];
5}
6
7interface FormComponentProps {
8 name: string;
9 label?: string;
10 disabled?: boolean;
11}
12
13interface ButtonProps extends ThemableComponentProps, FormComponentProps {
14 loading?: boolean; // adding a loading state to `<Button />`
15}
16
17const Button = (props: ButtonProps): JSX.Element => {
18 const className = [
19 "button",
20 props.disabled ? 'button__disabled' : undefined,
21 `button__size-${props.size}`,
22 `button__type-${props.type}`,
23 props.classNames].filter(c => !!c).join(" ")
24 return (
25 <div className={className} id={`button-${props.name}`}>
26 <div className={"button--label"}>
27 {props.loading ? "Loading ..." : props.label || "Continue"}
28 </div>
29 </div>
30 );
31};
32
As discussed in the introduction, TypeScript is expecting functional components to provide, optionally, a first argument that would be the component's props.
The JSX.Element type, exposed by React, defines a valid JSX element type.
Object destructuring for arguments
Most applications leverage object destructuring on props for a clearer code, as follows:
1interface ThemableComponentProps {
2 type: "primary" | "secondary";
3 size: "small" | "medium" | "large";
4 classNames: string[];
5}
6
7interface FormComponentProps {
8 name: string;
9 label?: string;
10 disabled?: boolean;
11}
12
13interface ButtonProps extends ThemableComponentProps, FormComponentProps {
14 loading?: boolean; // adding a loading state to `<Button />`
15}
16
17const Button = ({
18 classNames,
19 name,
20 size,
21 type,
22 label,
23 loading,
24 disabled,
25}: ButtonProps) => {
26 const className = [
27 "button",
28 disabled ? "button__disabled" : undefined,
29 `button__size-${size}`,
30 `button__type-${type}`,
31 classNames,
32 ]
33 .filter((c) => !!c)
34 .join(" ");
35 return (
36 <div className={className} id={`button-${name}`}>
37 <div className={"button--label"}>
38 {loading ? "Loading ..." : label || "Continue"}
39 </div>
40 </div>
41 );
42};
43
Why our <Button> is now missing a JSX.Element return type?
As you might have noticed, our last refactoring also dropped the return type of the component.
This has been done to illustrate the following TypeScript rule:
Meaning that, when it comes to functional components, you don't need to explicitly specify a return type since TypeScript will assume that it should resolve to JSX.Element.
Typing a custom hook
Modern React applications mostly rely on React core hooks (
useState()) and custom hooks.
Custom React hooks are functions that follow 2 very simple rules - on top of the
rules of hooks:
- A function is a custom hook if it relies on other hooks (ex: useState(), useMemo())
- A custom hook should follow the naming convention: use[HookName]()
Since custom hooks are function, typing them is straightforward, consider the following useCurrentUserName() custom hook:
1const useCurrentUserName = ({ format }: { format: 'short' | 'long' }): string => {
2 // get current user object from a context
3 const user = useContext(CurrentUserContext)
4
5 // format user's name and return it...
6}
7
8// ...
9
10const userName = useCurrentUserName({ format: 'short' }) // => "Charly P."
11
Inline argument typings
You might have noticed in the example above, that the object argument types are inlined:
({ format }: { format: 'short' | 'long' })
This is a common practice when types are simple and short, avoiding the overhead of building a separate interface.
However, beware of using it along with object destructuring since it can become confusing:
({ format: formatValue }: { format: 'short' | 'long' })
Here, format: formatValue is a destructuring assignment to a local variable called formatValue.
We can improve our useCurrentUserName() hook by adding a default format value as follows:
1const useCurrentUserName = (
2 { format }: { format: 'short' | 'long' } = { format: 'short' }
3): string => {
4 // get current user object from a context
5 const user = useContext(CurrentUserContext)
6
7 // format user's name and return it...
8}
9
When using a default value, no ? type prefix is necessary.
TypeScript automatically infers that the type can be undefined which is equivalent to the following type:
1const useCurrentUserName = (
2 { format }: { format: 'short' | 'long' } = { format: 'short' }
3): string => { /* ... */ }
4
5// translates to
6
7const useCurrentUserName = (
8 { format }?: { format: "short" | "long"; }
9): string => { /* ... */ }
10
Functions as object properties
Back to our <Button /> component, which could not really be a real-world component without a classic onClick props.
Let's add a onClick props to the ButtonProps type:
1interface ButtonProps extends ThemableComponentProps, FormComponentProps {
2 loading?: boolean;
3 // our onClick callback props
4 onClick: () => void
5 // ^ not ":" but "=>"
6}
7
Typing function on variable or object properties is very similar to typing functions;
However, beware, the return type is declared using the => operator.
Let's see a comprehensive example with a custom useToggle() hook:
1import { useState, useCallback } from 'react'
2
3interface UseToggleReturnValue {
4 value: boolean
5 setToFalse: () => void
6 setToTrue: () => void
7 toggle: () => void
8}
9
10const useToggle = (defaultValue = false): UseToggleReturnValue => {
11 const [value, setValue] = useState(defaultValue)
12
13 return {
14 setToFalse: useCallback(
15 () => { setValue(false) },
16 [setValue]
17 ),
18 setToTrue: useCallback(
19 () => { setValue(true) },
20 [setValue]
21 ),
22 toggle: useCallback(
23 () => { setValue(!value) },
24 [setValue, value]
25 ),
26 value
27 }
28}
29
30// Usage:
31const {
32 value: modalVisible,
33 setToFalse: closeModal,
34 toggle: toggleModal
35} = useToggle(true)
36
37toggleModal() // value => false
38
Side note: Type functions with interface
So far, we have learned how to provide types to functions by annotating the functions directly.
However, a cleaner alternative is possible by leveraging the interface keyword as it follows:
1import { useState, useCallback } from 'react'
2
3interface UseToggle {
4 // note, here the function return type is
5 // prefixed with a ":"
6 (defaultValue?: boolean): {
7 value: boolean
8 // as property, a function return type is
9 // prefixed with a "=>"
10 setToFalse: () => void
11 setToTrue: () => void
12 toggle: () => void
13 }
14}
15
16const useToggle: UseToggle = (defaultValue = false) => {
17 const [value, setValue] = useState(defaultValue)
18
19 return {
20 setToFalse: useCallback(
21 () => { setValue(false) },
22 [setValue]
23 ),
24 setToTrue: useCallback(
25 () => { setValue(true) },
26 [setValue]
27 ),
28 toggle: useCallback(
29 () => { setValue(!value) },
30 [setValue, value]
31 ),
32 value
33 }
34}
35
By adding a () at the root of the interface, we provide a callable signature.
Using interface to type functions is only possible for anonymous functions, assigned to a variable.