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?
- Install and Run TypeScript
- Fundamental Types
- Type Inference
- Interfaces and Type Aliases
- Generics
- Setting Up a React Project with TypeScript
- Function Components with TypeScript
- Working with Forms and Refs
- Working with Functions as Props
- Working with State
- Working with Context
- Configuring TypeScript: tsconfig.json
- Final Words
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
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.
Type Inference: TypeScript can infer types based on the values you assign, so we don’t always need to explicitly declare types.
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.
Interfaces and Generics: TypeScript introduces interfaces and generics, which allow for more flexible and reusable code by enabling complex type definitions and contracts.
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.
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?
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.
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.
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.
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.
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
andb: number
annotations specify thata
andb
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.
- If we try to call
In this example we can see a couple of benefits introduced by TypeScript:
Type Safety: The function guarantees that it only works with numbers, reducing the chance of runtime errors.
Documentation: The type annotations act as a form of documentation, making it clear what types of arguments the function expects and what it returns.
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:
Name: JavaScript and TypeScript Nightly Description: Enables typescript@next to power VS Code's built-in JavaScript and TypeScript support
Name: Pretty TypeScript Errors Description: Make TypeScript errors prettier and more human-readable in VSCode
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.
React.FC
(orReact.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 thechildren
prop included, which is often useful when we want to allow other components or elements to be nested inside our component.
Defining Props:
In the example,
{ items: string[] }
is the type definition for the props that theTodos
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[] }
.
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.
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.
- Finally, the component is exported using
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 thattodoTextInput
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 typeHTMLInputElement | null
.
Initial Value
null
:- The initial value of
todoTextInput
isnull
, 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.
- The initial value of
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 asReact.FormEvent
, which is a type provided by React for form events. This ensures thatevent
has the properties and methods available for form events, such aspreventDefault()
.
- The
const enteredText = todoTextInput.current!.value;
Non-Null Assertion Operator (
!
):todoTextInput.current!
is using the non-null assertion operator (!
), which tells TypeScript that we are certaincurrent
is notnull
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 thevalue
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
compilerOptions
:- This section contains various options that control how the TypeScript compiler (tsc) behaves.
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.
useDefineForClassFields
:"useDefineForClassFields": true
instructs the compiler to use thedefine
behavior for class fields, which aligns with the latest ECMAScript standard for how class fields are initialized.
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, likeNodeList
.
module
:"module": "ESNext"
sets the module system for the output toESNext
, which supports the latest ECMAScript module syntax. This is often used with modern JavaScript bundlers that can handle ES modules.
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
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.
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.
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.
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.
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.
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 requireimport React from 'react'
at the top of files that use JSX.
Linting Options
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.
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.
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.
noFallthroughCasesInSwitch
:"noFallthroughCasesInSwitch": true
prevents unintentional fall-through inswitch
statements, where the execution moves to the next case if abreak
statement is missing. This reduces potential bugs inswitch
statements.
The include
Options
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 thesrc
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! 🖖