TypeScript in React: Elevating Your Frontend Development Skills

TypeScript in React: Elevating Your Frontend Development Skills

Unlock the Full Potential of Static Typing in Your React Projects

Table of contents

What is TypeScript?

TypeScript is a statically typed superset of JavaScript, developed and maintained by Microsoft. It’s called a "superset" of JavaScript because it extends the capabilities of JavaScript by adding additional features while maintaining compatibility with existing JavaScript code. This means that any valid JavaScript code is also valid TypeScript code.

TypeScript extends JavaScript by adding type annotations, which provide static type checking at compile time. TypeScript code is written in .ts files and, before it can be run in the browser or on a server, it’s transpiled into standard JavaScript using tools like the TypeScript compiler (tsc).

There are some key features of TypeScript that are worth highlighting.

Key Features of TypeScript

  1. Static Typing: TypeScript allows us to define the types of variables, function parameters, return values, and object properties. This helps catch type-related errors during development, rather than at runtime.

  2. Type Inference: TypeScript can infer types based on the values you assign, so we don’t always need to explicitly declare types.

  3. Modern JavaScript Features: TypeScript supports all modern JavaScript features, including ES6+ features, and compiles them down to JavaScript versions that are compatible with older environments if needed.

  4. Interfaces and Generics: TypeScript introduces interfaces and generics, which allow for more flexible and reusable code by enabling complex type definitions and contracts.

  5. Tooling and IDE Support: TypeScript provides excellent tooling support. Most modern IDEs, like Visual Studio Code, provide features like autocompletion, type checking, and inline documentation, which make development more efficient.

  6. Optional Static Typing: While TypeScript encourages static typing, it’s optional. We can gradually introduce types into a JavaScript codebase, making the transition to TypeScript more manageable.

All these features sound good, but why should we use TypeScript? There are several compelling reasons why TypeScript has become increasingly popular among developers and why it can be a valuable addition to our development toolkit.

Why Should We Use TypeScript?

  1. Improved Code Quality:

    • Early Error Detection: By catching errors at compile time rather than at runtime, TypeScript helps prevent common bugs, such as typos, type mismatches, and incorrect function signatures.

    • Better Documentation: Type annotations serve as self-documenting code, making it easier for developers to understand what a function or variable is expected to do without needing to refer to external documentation.

  2. Enhanced Developer Experience:

    • Intelligent Code Completion: TypeScript provides better autocompletion and IntelliSense in IDEs, which can speed up development and reduce errors.

    • Refactoring Support: TypeScript’s static type system makes it easier and safer to refactor code. The TypeScript compiler can highlight potential issues when renaming variables, moving functions, or restructuring your codebase.

  3. Scalability:

    • Large-Scale Applications: For large projects, TypeScript’s type system can help manage and understand complex codebases, making it easier to maintain and scale applications.

    • Collaboration: In teams, TypeScript can enforce consistency and clarity, making it easier for multiple developers to work on the same codebase.

  4. Compatibility with JavaScript:

    • Gradual Adoption: We can start using TypeScript incrementally in existing JavaScript projects. TypeScript files can coexist with JavaScript files, allowing teams to adopt it at their own pace.

    • Interoperability: TypeScript is fully compatible with existing JavaScript libraries and frameworks. We can use JavaScript libraries in TypeScript projects and even write TypeScript declarations for them.

  5. Popular Framework Support:

    • React, Angular, Vue: TypeScript is widely used in modern frontend frameworks and libraries. Angular, for instance, is built with TypeScript, and React and Vue have strong TypeScript support, making it easier to use TypeScript in our frontend projects.

Now that we now a little bit abut TypeScript features and the reasons why we should use it in our projects, lets take a look at how TypeScript code actually looks like.

Basic TypeScript Example

Let's say we have a function that takes two numbers as input, adds them together, and returns the result. In JavaScript, this might look like:

function add(a, b) {
  return a + b;
}

const result = add(5, 10);
console.log(result); // 15

This works, but there's no type checking. We could accidentally pass non-number values, leading to unexpected results.

Now let’s take a look how this example looks like when using TypeScript.

The Same Example in TypeScript

function add(a: number, b: number): number {
  return a + b;
}

const result = add(5, 10);
console.log(result); // 15

// TypeScript will catch this error if we try to pass non-number arguments:
// const wrongResult = add(5, "10"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.

In the previous code snippet we can see TypeScript in action:

  • Type Annotations:

    • The a: number and b: number annotations specify that a and b must be numbers.

    • The : number after the function parameters indicates that the function returns a number.

  • Error Prevention:

    • If we try to call add(5, "10") with a string instead of a number, TypeScript will throw an error during development, preventing potential bugs.

In this example we can see a couple of benefits introduced by TypeScript:

  1. Type Safety: The function guarantees that it only works with numbers, reducing the chance of runtime errors.

  2. Documentation: The type annotations act as a form of documentation, making it clear what types of arguments the function expects and what it returns.

  3. Better Tooling: When using an IDE like Visual Studio Code, TypeScript provides autocompletion and inline type checking, which helps prevent mistakes.

Install and Run TypeScript

Enough with the theory. Let’s get our hands dirty.

In order to install TypeScript, we could follow the indications in the official documentation, and run:

npm install typescript --save-dev

If we are using VSCode, these extensions will improve the overall use of TypeScript in our development environment:

Running the TypeScript Compiler

In order to compile our TypeScript files into JavaScript files that the browser can understand, we need to run:

npx tsc --init # <- run this the first time to generate a tsconfig.json file

npx tsc # <- run this to compile the project

# or

npx tsc <file_name>

Here’s an example of errors in our code being picked up by TypeScript:

After running the npx tsc command, a JavaScript file will be generated as a result of the compilation process.

Fundamental Types

Now let’s go back to the theory so we can learn TypeScript fundamental types.

1. The any Type

The any type can represent any JavaScript value. It's a "catch-all" type that disables type checking for that variable and the default type when we don’t specify a type explicitly.

It's recommended to avoid any because it defeats the purpose of using TypeScript for type safety.

let myVar: any; // <- the 'any' keyword can be ommited
myVar = 42;
myVar = "text";

2. Primitive Types

string: Represents textual data.

let userName: string = "Damian";

number: Represents numeric data, including integers and floating-point numbers.

let age: number = 30;

boolean: Represents true or false.

let isActive: boolean = true;

null and undefined: Represent the absence of a value.

let emptyValue: null = null;
let notAssigned: undefined = undefined;

3. Array Types

Arrays can be defined with a specific type for their elements.

The type is defined as type[].

let hobbies: string[] = ["photography", "hiking"];
let lottoNumbers: number[] = [1, 2, 3];

4. Object Types

Objects can be typed by specifying the types of their properties.

Define object types using { property: type; }.

let person: {
  name: string;
  age: number;
} = {
  name: "Damian",
  age: 21 // I wish...
};

5. Union Types

Union types allow a variable to hold multiple types.

Use the pipe (|) to combine types.

let id: number | string;
id = 42; // valid
id = "42"; // also valid

6. Tuple Types

Tuples allow you to express an array with a fixed number of elements, each with a specific type.

Define using [type1, type2, ...].

let address: [string, number];
address = ["Main Street", 123];

7. Enum Types

Enums allow us to define a set of named constants.

Useful for representing a fixed set of related values.

enum Color {
  Red,
  Green,
  Blue
}
let favoriteColor: Color = Color.Green;

8. Function Types

You can define the types for the parameters and return value of a function.

This helps ensure functions are called with the correct arguments.

// Regular function definition
function add(a: number, b: number): number {
  return a + b;
}
// Arrow function definition
const add: (a: number, b: number) => number = (a, b) => {
  return a + b;
}

9. Void Type

Used when a function does not return a value.

function logMessage(message: string): void {
  console.log(message);
}

10. Type Aliases

Allows us to create custom types using the type keyword. By convention, type aliases names start with a capital letter.

This simplifies complex type definitions.

type Person = {
  name: string;
  age: number;
};

let person1: Person = { name: "Harry", age: 35 };
let person2: Person = { name: "Ron", age: 34 };

Type Inference

Let’s now move on to a TypeScript characteristic that could make our lives easier when working with types.

Type inference in TypeScript is a feature where the compiler automatically infers the types of variables, functions, and other expressions based on the values or context in which they are used, without the need for explicit type annotations.

If we initialize a variable when we declare it, TypeScript infers its type based on the assigned value.

let age = 30; // TypeScript infers that 'age' is of type 'number'
age = "thirty"; // Error: Type '"thirty"' is not assignable to type 'number'

For functions, TypeScript infers the return type based on the function's return statements.

function add(a: number, b: number) {
  return a + b; // TypeScript infers the return type as 'number'
}

const result = add(5, 10); // 'result' is inferred to be of type 'number'

If we create an array and initialize it with values, TypeScript infers the type of the array elements.

let names = ["Alice", "Bob", "Charlie"]; // TypeScript infers 'string[]' as the type
names.push(42); // Error: Argument of type 'number' is not assignable to parameter of type 'string'

Benefits of Type Inference

Type inference has some nice benefits:

  • Less Boilerplate: We can write less code because we don't have to explicitly annotate types in many cases.

  • Readability: It makes the code cleaner and easier to read since the types are automatically inferred from the context.

  • Type Safety: Even though types aren't explicitly annotated, we still get the benefits of TypeScript's type checking.

When to Rely on Inference

We shouldn’t always rely on type inference, but there are some situations when depending on type inference can be a good idea: simple cases like variables, function return types, and straightforward data structures.

For more complex scenarios or when the inferred type might be unclear, it's better to use explicit annotations to make the code's intention clear.

Type Inference Example

Here's a practical example of type inference:

let greeting = "Hello, world!"; // TypeScript infers the type as 'string'

function multiply(a: number, b: number) {
  return a * b; // TypeScript infers the return type as 'number'
}

const result = multiply(5, 10); // 'result' is inferred to be of type 'number'

let numbers = [1, 2, 3]; // TypeScript infers the type as 'number[]'
numbers.push(4); // This is fine
// numbers.push("five"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'

In this example, even though we didn't explicitly define the types for greeting, result, and numbers, TypeScript automatically inferred the correct types based on the context.

Interfaces and Type Aliases

In TypeScript, interfaces and type aliases are both tools that allow us to define custom types. They help us describe the shape of objects, specify the types of data structures, and enforce a consistent structure across our code. Although they have similar purposes, there are some differences in their syntax, capabilities, and best-use scenarios. Let's break down what each one is and how to use them effectively.

Interfaces

An interface is a way to define a contract for the shape of an object. Interfaces are primarily used to describe the structure of objects, including their properties and methods. They can also be extended or implemented, which makes them very useful for object-oriented programming and large-scale applications.

Here's a basic example of how to define and use an interface in TypeScript:

interface User {
  name: string;
  age: number;
  isAdmin: boolean;
}

const user: User = {
  name: "Alice",
  age: 30,
  isAdmin: true,
};

In this example User is an interface that describes an object with three properties: name, age, and isAdmin. The user variable is then defined to adhere to the User interface, ensuring it has all the required properties with the correct types.

Extending Interfaces

One powerful feature of interfaces is that they can be extended, allowing us to build upon existing types:

interface Person {
  name: string;
  age: number;
}

interface Employee extends Person {
  employeeId: number;
}

const employee: Employee = {
  name: "Bob",
  age: 25,
  employeeId: 12345,
};

Here, Employee extends Person, meaning it inherits the properties of Person and adds its own (employeeId).

Optional Properties and Read-Only Properties

Interfaces can define optional properties and read-only properties:

interface Car {
  brand: string;
  model: string;
  year?: number; // Optional property
  readonly vin: string; // Read-only property
}

const myCar: Car = {
  brand: "Toyota",
  model: "Corolla",
  vin: "1234567890",
};

// myCar.vin = "0987654321"; // Error: Cannot assign to 'vin' because it is a read-only property.

In this example, year? indicates that year is an optional property and readonly vin means that the vin property cannot be modified once it is set.

Type Aliases

A type alias is another way to define a type in TypeScript. It can describe objects, primitive types, union types, intersections, and even function signatures. Type aliases are more flexible than interfaces because they can represent more than just the shape of an object.

Defining a Type Alias

Here's how to define a type alias:

type Point = {
  x: number;
  y: number;
};

const point: Point = {
  x: 10,
  y: 20,
};

In this example, Point is a type alias that defines an object with two properties: x and y.

Type Aliases for Primitive Types and Unions

Type aliases are not limited to objects; they can also be used to create unions, intersections, and custom types:

type ID = string | number;

let userId: ID;
userId = "abc123";
userId = 456;

type Result = "success" | "failure" | "pending";

const taskStatus: Result = "success";

In the above example, ID is a type alias that can be either a string or a number. Result is a type alias that can only be one of the three specified string literals.

Type Aliases for Function Types

We can also use type aliases to define function signatures:

type Greet = (name: string) => string;

const greetUser: Greet = (name) => {
  return `Hello, ${name}!`;
};

console.log(greetUser("Alice")); // Output: Hello, Alice!

This feature will become one of the most used ones when working with TypeScript in a React project.

Interfaces vs. Type Aliases

While both interfaces and type aliases can be used to describe the shape of objects, there are some key differences:

  • Use Cases:

    • Interfaces are generally preferred for defining the structure of objects, especially when those objects are meant to be extended or implemented by classes.

    • Type aliases are more versatile, as they can describe objects, unions, intersections, and primitives. They are often used for complex types or when using union and intersection types.

  • Extensibility:

    • Interfaces can be extended using the extends keyword, which is useful in many object-oriented programming scenarios.

    • Type aliases cannot be extended in the same way, but they can combine types using intersections (&).

  • Merging:

    • Interfaces can be merged across multiple declarations. This is particularly useful when working with third-party libraries where you might want to extend existing interfaces.

    • Type aliases cannot be merged.

  • Syntax:

    • The syntax for interfaces is more concise when defining object shapes, while type aliases offer more flexibility in representing different kinds of types.
  • Open vs Close:

    • One major difference between type aliases vs interfaces are that interfaces are open and type aliases are closed. This means we can extend an interface by declaring it a second time:

        interface Kitten {
          purrs: boolean;
        }
      
        interface Kitten {
          colour: string;
        }
      

      But we can’t extend type aliases in the same way:

        type Puppy = {
          color: string;
        };
      
        type Puppy = { // it throws an error
          toys: number;
        };
      

When to Use Which?

We should use interfaces when defining the shape of objects and when we expect to use inheritance or class-based implementations.

We should use type aliases when we need to define more complex types like unions, intersections, or when you need the flexibility to define different kinds of types beyond just objects.

Generics

Generics in TypeScript are a powerful feature that allows us to create reusable and flexible components, functions, or types that can work with a variety of data types while maintaining type safety. They enable us to write code that is more abstract and can handle different types without losing the benefits of TypeScript's type-checking.

Generics is one of the key TypeScript features we’ll be using in our React projects.

Without generics, we might have to write multiple versions of the same function or component for different data types. Generics let us write a single version that works for any type, which makes our code more reusable and maintainable.

Basic Syntax of Generics

A generic type is defined using a type parameter, which is usually denoted by a single capital letter like T (short for "Type"). The type parameter is specified in angle brackets (<T>) and can be used in the function, class, or type alias.

Example: Generic Function 1

Let’s take a look at an example of a generic:

function identity<T>(value: T): T {
  return value;
}

const result1 = identity<string>("Hello, world!"); // result1 is of type 'string'
const result2 = identity<number>(42); // result2 is of type 'number'

In this example, the identity function takes a type parameter T. When you call the function, you can specify the type explicitly (e.g., identity<string>("Hello, world!")), or TypeScript can infer it automatically.

Example: Generic Function 2

Here's another simple example of a generic function:

function insertAtBeginning<T>(array: T[], value: T) {
  const newArray = [value, ...array];
  return newArray;
}

const demoArray = [1, 2, 3];

const numberArray = insertAtBeginning(demoArray, -1);
// Equivalent to: function insertAtBeginning<number>(array: number[], value: number): number[]

const stringArray = insertAtBeginning(["a", "b", "c"], "z");
// Equivalent to: function insertAtBeginning<string>(array: string[], value: string): string[]

const wrongStringArray = insertAtBeginning(["a", "b", "c"], 42);
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.typescript(2345)

In this example, the insertAtBeginning function takes a type parameter T and two function parameters: the first one will be an array containing all the elements of the same type T, and the second one will be a single value of the same type T. When we call the function, TypeScript can infer all the types automatically.

If we try to pass two different types to the function, as in the wrongStringArray case, it will fail.

Example: Generic Classes

Generics can also be used with classes:

class Box<T> {
  content: T;

  constructor(content: T) {
    this.content = content;
  }

  getContent(): T {
    return this.content;
  }
}

const stringBox = new Box<string>("A string");
console.log(stringBox.getContent()); // "A string"

const numberBox = new Box<number>(123);
console.log(numberBox.getContent()); // 123

The Box class is generic, meaning it can hold any type of content. The type parameter T is used in the property content, the constructor parameter, and the return type of the getContent method. We can create instances of Box with different types, such as string or number.

💡 Note: When we define an array type, we normally do this:

let numbersArray: number[] = [1, 2, 3]

But the number[] notation is syntactic sugar. What we are actually using is:

let numbersArray: Array<number> = [1, 2, 3]

So, we are actually using generics here. There’s an Array generic class that receives a parameter of type number and returns an array of numbers.

Example: Generic Interfaces

Generics can also be used with interfaces:

interface Pair<T, U> {
  first: T;
  second: U;
}

const stringNumberPair: Pair<string, number> = {
  first: "one",
  second: 1,
};

const booleanArrayPair: Pair<boolean, boolean[]> = {
  first: true,
  second: [true, false, true],
};

The Pair interface takes two type parameters, T and U, which represent the types of the first and second properties, respectively. This allows us to create pairs of different types, like string and number or boolean and boolean[].

Example: Generic Constraints

We can also add constraints to generics to ensure that the types used meet certain requirements. For example:

function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

console.log(getLength("Hello")); // 5
console.log(getLength([1, 2, 3, 4])); // 4
// console.log(getLength(42)); // Error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'

The getLength function is generic, but it requires that the type T has a length property. This constraint is defined using T extends { length: number }. This ensures that we can only call getLength with types that have a length property, such as strings or arrays.

Setting Up a React Project with TypeScript

Let’s now finally start applying what we have learned about TypeScript in a React project.

In order to work with TypeScript in a React project, we need to configure our building tool (Vite, Create React App, etc.) to support TypeScript.

If we are working with Vite, we can implement TypeScript in our React project like so:

❯ npm create vite@latest  

Need to install the following packages:
create-vite@5.5.1
Ok to proceed? (y) 
✔ Project name: … react-ts
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in /Users/damian/Programming/Workspaces/react-the-complete-guide-course-code/30-typescript-foundations/react-ts...

Done. Now run:

  cd react-ts
  npm install
  npm run dev

❯ cd react-ts

❯ npm install

added 195 packages, and audited 196 packages in 39s

42 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

❯ npm run dev

> react-ts@0.0.0 dev
> vite

  VITE v5.4.0  ready in 1738 ms

  ➜  Local:   <http://localhost:5173/>
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

By selecting the Select a variant: › TypeScript our project will now have files with tsx extension instead of jsx, indicating those are TypeScript files. Vite will take care of transpiling TypeScript into JavaScript during the build process.

Function Components with TypeScript

In React, a function component is a JavaScript function that returns JSX (a syntax extension that looks like HTML, used to describe the UI structure).

Let’s take a look at an example without TypeScript:

function Todos(props) {
  return <ul>{
    props.items.map(
      item => <li key={item}>{item}</li>
    )
  }</ul>;
}

When working with React function components, we can explicitly define the types for the props that our component will receive.

TypeScript enhances this function component by adding static type checking. This allows us to catch errors at compile-time rather than at runtime, making our code safer and easier to maintain.

import React from 'react';

const Todos: React.FC<{ items: string[] }> = (props) => {
  return (
    <ul>
      {props.items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
};

export default Todos;

Let’s try to understand what’s going on here.

  1. React.FC (or React.FunctionComponent):

    • React.FC is a generic type provided by React that stands for "Function Component."

    • It is a type definition that ensures our function component adheres to the standard React function component structure.

    • By using React.FC, we automatically get the children prop included, which is often useful when we want to allow other components or elements to be nested inside our component.

  2. Defining Props:

    • In the example, { items: string[] } is the type definition for the props that the Todos component will receive.

    • This definition means that the items prop is required and must be an array of strings.

    • If we want to make a prop optional, we can add a question mark (?) after the prop name, like { items?: string[] }.

  3. Using Props:

    • Inside the function component, we can access the props via the props object.

    • In this case, props.items is used to map over the array and render each item as a list item (<li>).

    • The key attribute in the list item is important for React's reconciliation process when rendering lists. This has nothing to do with TypeScript.

  4. Exporting the Component:

    • Finally, the component is exported using export default Todos;, making it available for import and use in other parts of our application.

Key Benefits of Using TypeScript with React Function Components

These are the key benefits we get when using TypeScript in a function component (React.FC):

  • Type Safety: By defining the types of props, we ensure that our component only receives the expected types, reducing runtime errors.

  • Documentation: The types serve as self-documentation, making it easier to understand what a component expects.

  • Code Completion: TypeScript provides better code completion and IDE support, helping us write code more efficiently.

  • Optional Props: TypeScript makes it easy to handle optional props and provide default values if needed.

Working with Forms and Refs

Let’s now discuss how wee can work with forms and refs in a TypeScript-based React component with this ecample:

import { useRef } from "react";

const NewTodo = () => {
  const todoTextInput = useRef<HTMLInputElement>(null);

  const submitHandler = (event: React.FormEvent) => {
    event.preventDefault();

    // const enteredText = todoTextInput.current?.value
    const enteredText = todoTextInput.current!.value;

    if (enteredText.trim().length === 0) {
      // Throw error
      return;
    }
  };

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="todo">Todo text</label>
      <input type="text" name="" id="todo" ref={todoTextInput} />
      <button>Add ToDo</button>
    </form>
  );
};

export default NewTodo;

💡 Note: Some of the code used in this article has been created as part of my notes wilst taking the Udemy course React - The Complete Guide 2024 (incl. Next.js, Redux), so expect certain similarities with the code shown in that course.

This code is a TypeScript-based React component that demonstrates several important TypeScript and React concepts. Let's break down and explain each of these concepts:

  • const todoTextInput = useRef<HTMLInputElement>(null);

    • useRef with TypeScript:

      • Here, useRef is used to create a reference to an HTML input element. The type parameter <HTMLInputElement> is passed to specify the type of DOM element that todoTextInput will reference.

      • Type Parameter <HTMLInputElement>:

        • TypeScript uses this to enforce that the ref object will be tied to an HTMLInputElement, ensuring type safety. For example, todoTextInput.current will be inferred to be of type HTMLInputElement | null.

      • Initial Value null:

        • The initial value of todoTextInput is null, indicating that the ref is not yet pointing to any DOM element when the component first renders. This is common when using refs to access elements that will be rendered later.
  • const submitHandler = (event: React.FormEvent) => {}

    • Event Handling in TypeScript:

      • This line defines a submitHandler function that is triggered when the form is submitted.

      • Type Annotation React.FormEvent:

        • The event parameter is explicitly typed as React.FormEvent, which is a type provided by React for form events. This ensures that event has the properties and methods available for form events, such as preventDefault().
  • const enteredText = todoTextInput.current!.value;

    • Non-Null Assertion Operator (!):

      • todoTextInput.current! is using the non-null assertion operator (!), which tells TypeScript that we are certain current is not null at this point in the code.

      • This operator is useful when we are sure that a value is non-null or non-undefined, but TypeScript’s type system isn't aware of it. However, it should be used with caution as it can bypass TypeScript’s safety checks.

      • If we don’t know if the element is going to be null or have a value, we can use the optional chaining operator ? operator instead.

    • Accessing DOM Element Value:

      • todoTextInput.current!.value accesses the value property of the input element, which contains the text entered by the user.

Working with Functions as Props

When working with functions as props in React TypeScript, it’s important to define the types of the props, including the functions.

When passing a function as a prop, we need to define its type signature in the props interface. This includes specifying the function's parameters and return type.

For example, if we have a function (onClick) that takes a string as a parameter (message) and returns nothing (void), we can define it like this:

type MyComponentProps = {
  onClick: (message: string) => void;
};

Remember that, if the function returns a value, we specify the return type instead of void.

Let’s say we now have a Button component that accepts a click handler function:

import React from 'react';

type ButtonProps = {
  onClick: (message: string) => void;
};

const Button: React.FC<ButtonProps> = ({ onClick }) => {
  return (
    <button onClick={() => onClick('Button clicked!')}>
      Click Me
    </button>
  );
};

export default Button;

In this example, the onClick function prop takes a string argument and returns void. When the button is clicked, the onClick function is invoked with the message "Button clicked!".

When we use this Button component, we need to pass a function that matches the expected signature:

import React from 'react';
import Button from './Button';

const App: React.FC = () => {
  const handleClick = (message: string) => {
    console.log(message);
  };

  return (
    <div>
      <Button onClick={handleClick} />
    </div>
  );
};

export default App;

Here, handleClick is passed as a prop to the Button component. Since handleClick matches the expected function signature, TypeScript ensures type safety.

Handling Optional Function Props

If a function prop is optional, we can add a question mark (?) to make it optional in the type definition:

type ButtonProps = {
  onClick?: (message: string) => void;
};

In the component, you would then check if the function exists before calling it:

const Button: React.FC<ButtonProps> = ({ onClick }) => {
  return (
    <button onClick={() => onClick?.('Button clicked!')}>
      Click Me
    </button>
  );
};

This ensures that onClick is only invoked if it has been provided.

Example with Multiple Function Props

If our component requires multiple functions as props, we can define them all in the props type:

type ModalProps = {
  onClose: () => void;
  onConfirm: (confirmed: boolean) => void;
};

const Modal: React.FC<ModalProps> = ({ onClose, onConfirm }) => {
  return (
    <div>
      <button onClick={() => onConfirm(true)}>Confirm</button>
      <button onClick={onClose}>Close</button>
    </div>
  );
};

In this example, Modal accepts two functions: onClose, which takes no parameters and returns void, and onConfirm, which takes a boolean and returns void.

By carefully typing function props, we enhance the maintainability and reliability of our React components, catching potential errors early during development.

Working with State

When working with state in React using TypeScript, we can leverage TypeScript's type system to ensure that our state is well-defined and type-safe.

Defining the State Type

First, we need to define the type of data that our state will hold. Depending on the complexity of our state, this could be a primitive type (like a string or number), an array, an object, or even a more complex structure like a union type.

For example, if our state is a simple string:

const [todo, setTodo] = useState<string>("");

If our state is an array of objects:

type Todo = {
  id: string;
  text: string;
  completed: boolean;
};

const [todos, setTodos] = useState<Todo[]>([]);

Using useState with Type Inference

TypeScript often infers the type based on the initial state value. For instance:

const [count, setCount] = useState(0); // TypeScript infers count is a number

However, if our state starts with null or an empty array, TypeScript may infer it as null or never[], which isn't always what we want. In such cases, we should explicitly provide the type.

Handling Complex State

For more complex state shapes, like objects or arrays, we might want to define a specific type for the state:

type FormState = {
  name: string;
  email: string;
  age: number;
};

const [formState, setFormState] = useState<FormState>({
  name: "",
  email: "",
  age: 0,
});

In this example, we ensure that formState has a well-defined shape, and any updates to it must conform to this structure.

Optional State and Union Types

Sometimes our state might be optional or can hold different types. We can use union types or null to represent such cases:

type User = {
  id: string;
  name: string;
};

const [user, setUser] = useState<User | null>(null);

In this example, user can either be a User object or null. This is useful when dealing with data that might not be immediately available, like user data fetched from an API.

Handling State Changes with Type Safety

Using TypeScript, we can ensure that our state updates are type-safe. This prevents common mistakes like trying to update the state with an incompatible type.

For example, trying to update the formState with an incompatible type would result in a TypeScript error:

type FormState = {
  name: string;
  email: string;
  age: number;
};

const [formState, setFormState] = useState<FormState>({
  name: "",
  email: "",
  age: 0,
});

setFormState({ name: "New Name", email: "new@example.com" }); // Error: age is missing

Working with Context

Using React's Context API with TypeScript allows us to define the types of data that the context will hold and ensures type safety across our components. Let’s see next how we can effectively use Context in React with TypeScript.

Creating a Context with TypeScript

When creating a context, we first define the type for the context value. For example, let's say we are working with a theme context:

// Step 1: Define the type for the context value
type ThemeContextType = {
  theme: string;
  toggleTheme: () => void;
};

// Step 2: Create the context with a default value
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);

Providing the Context

Next, we create a provider component that will supply the context value to its child components:

// Step 3: Create the provider component
const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setTheme] = React.useState("light");

  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

Consuming the Context

To use the context value in a component, we can use the useContext hook. TypeScript will automatically infer the type of the context value:

import React, { useContext } from "react";

const ThemeToggleButton: React.FC = () => {
  // Step 4: Consume the context value
  const themeContext = useContext(ThemeContext);

  // TypeScript ensures that themeContext is not undefined
  if (!themeContext) {
    throw new Error("ThemeToggleButton must be used within a ThemeProvider");
  }

  const { theme, toggleTheme } = themeContext;

  return (
    <button onClick={toggleTheme}>
      Current Theme: {theme}
    </button>
  );
};

Handling Undefined Context

In the example above, we handle the case where the context might be undefined. This can happen if a component tries to consume the context without being wrapped by the provider. TypeScript's strict null checks will help catch these potential issues.

Alternatively, we can provide a default context value instead of undefined to avoid this check:

const defaultContextValue: ThemeContextType = {
  theme: "light",
  toggleTheme: () => {},
};

const ThemeContext = React.createContext<ThemeContextType>(defaultContextValue);

Using Context in a Larger Application

To integrate this into a larger application, we would wrap our application (or a portion of it) with the ThemeProvider, as we normally do when working with the Context API:

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(
  <ThemeProvider>
    <App />
  </ThemeProvider>,
  document.getElementById("root")
);

Inside App, any component that needs access to the theme can use the useContext hook as shown above.

Configuring TypeScript: tsconfig.json

Let’s now finish by discussing the tsconfig.json configuration file.

The tsconfig.json file is a configuration file for the TypeScript compiler. It specifies the root files and the compiler options required to compile a TypeScript project. Let's break down the following tsconfig.json example:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

General Settings

  1. compilerOptions:

    • This section contains various options that control how the TypeScript compiler (tsc) behaves.
  2. target:

    • "target": "ES2020" specifies the target JavaScript version for the compiled output. Here, ES2020 is used, meaning the TypeScript code will be compiled to be compatible with ES2020 features.
  3. useDefineForClassFields:

    • "useDefineForClassFields": true instructs the compiler to use the define behavior for class fields, which aligns with the latest ECMAScript standard for how class fields are initialized.
  4. lib:

    • "lib": ["ES2020", "DOM", "DOM.Iterable"] defines the libraries to be included in the compilation.

      • ES2020 provides the ES2020 standard library.

      • DOM includes the standard DOM types, which are necessary for web development.

      • DOM.Iterable adds support for DOM types that are iterable, like NodeList.

  5. module:

    • "module": "ESNext" sets the module system for the output to ESNext, which supports the latest ECMAScript module syntax. This is often used with modern JavaScript bundlers that can handle ES modules.
  6. skipLibCheck:

    • "skipLibCheck": true disables type checking for all declaration files (.d.ts files). This can speed up the compilation process but might hide some potential type issues.

Bundler Mode Settings

  1. moduleResolution:

    • "moduleResolution": "bundler" adjusts how modules are resolved. This is optimized for bundlers like Webpack or Vite that manage module imports differently than Node.js or TypeScript’s default resolution strategy.
  2. allowImportingTsExtensions:

    • "allowImportingTsExtensions": true allows importing TypeScript files (.ts and .tsx) with their extensions in the import statements, which is often necessary when working with bundlers.
  3. isolatedModules:

    • "isolatedModules": true ensures that each file is treated as a separate module. This is essential when using a bundler or tools like Babel to transpile TypeScript.
  4. moduleDetection:

    • "moduleDetection": "force" forces the detection of modules even in files that don’t have explicit imports or exports, ensuring that all files are treated as modules.
  5. noEmit:

    • "noEmit": true prevents TypeScript from emitting any output files. This is useful in scenarios where TypeScript is used solely for type checking, and the actual JavaScript code is handled by a bundler.
  6. jsx:

    • "jsx": "react-jsx" specifies how JSX syntax should be transformed. react-jsx refers to the modern JSX runtime introduced in React 17, which does not require import React from 'react' at the top of files that use JSX.

Linting Options

  1. strict:

    • "strict": true enables strict type-checking options, which includes several flags that make TypeScript's type system more robust and error-prone. This is the recommended setting for most projects to catch potential issues early.
  2. noUnusedLocals:

    • "noUnusedLocals": true raises an error when local variables are declared but never used, helping to keep the codebase clean and free of unnecessary variables.
  3. noUnusedParameters:

    • "noUnusedParameters": true raises an error if a function parameter is declared but never used. This can help identify unused or unnecessary parameters in functions.
  4. noFallthroughCasesInSwitch:

    • "noFallthroughCasesInSwitch": true prevents unintentional fall-through in switch statements, where the execution moves to the next case if a break statement is missing. This reduces potential bugs in switch statements.

The include Options

  1. include:

    • "include": ["src"] specifies the files or directories that should be included in the TypeScript compilation. Here, it’s set to include all files within the src directory.

Final Words

Integrating TypeScript with React offers a powerful combination that enhances code quality, reduces runtime errors, and makes our applications more scalable. By leveraging TypeScript’s type system, we gain better insights into our codebase, which helps us catch potential issues early during development. Throughout this article, we've explored the benefits of using TypeScript in React projects, including type safety, improved tooling, and enhanced developer experience.

Whether we're building small components or large, complex applications, TypeScript ensures that our code remains robust and maintainable. As we become more comfortable using TypeScript with React, we can start to explore advanced patterns like generics, utility types, and type guards, further improving the flexibility and safety of our applications.

With these tools at our disposal, we can confidently build modern, reliable React applications that stand the test of time. So, let's start embracing TypeScript in our React projects and unlock its full potential today.

See you in the next one! 🖖

Did you find this article valuable?

Support Damian Demasi by becoming a sponsor. Any amount is appreciated!