Typescript in React

React.FC

The React.FC (Function Component) type was once a common way to define React functional components with TypeScript. However, it has become outdated and is now considered unnecessary in most cases.

Issues with React.FC:

Example using React.FC:

const Component: React.FC<{
    name: string;
    age: number;
    isActive: boolean;
    skills: string[];
}> = ({ name, age, isActive, skills }) => {
    return <div>Hello {name}</div>;
};

Type Aliases

A modern and preferred approach is to define component props using type aliases. This method provides better type safety and flexibility.

Benefits of Type Aliases:

type ComponentProps = {
    name: string;
    age: number;
    isActive: boolean;
    skills: string[];
};

function Component({ name, age, isActive, skills }: ComponentProps) {
    return <div>Hello {name}</div>;
};

This approach eliminates the downsides of React.FC, leading to cleaner, more maintainable TypeScript code in React applications. I personally prefer this approach over React.FC because it's more concise and type-safe.

Inference

TypeScript has powerful type inference capabilities, reducing the need for explicit type annotations in many cases. This makes code more concise while still maintaining type safety.

Type Inference with Props

TypeScript can automatically infer types from the function arguments, meaning that explicit return types like JSX.Element or React.ReactNode are not necessary.

type ComponentProps = {
    name: string;
    age: number;
    isActive: boolean;
    skills: string[];
};

function Component({ 
    name,
    age,
    isActive,
    skills 
}: ComponentProps) { 
    return <div>Hello {name}</div>;
};

Default Values and Type Inference

When a prop has a default value, TypeScript can infer its type without requiring explicit annotations.

function Component({ name = "John" }: { name: string }) {
    return <div>Hello {name}</div>;
};

// TypeScript can infer the type without the need for explicit type definitions.
function Component({ name = "John", age = 30 }) {
    return <div>{name} is {age} years old</div>;
}

// it's safer to define the props explicitly in complex cases.

By leveraging TypeScript's inference capabilities, developers can write cleaner and more maintainable React components while still benefiting from strong type safety.

Basic TypeScript Types

TypeScript provides various built-in types that enhance type safety and flexibility in React applications. Here are some fundamental types and their usage in defining component props.

Union Types

A union type allows a value to be one of several specified types. This is useful when a prop should accept a predefined set of values.

type Color = "red" | "green" | "blue"; 

Tuple Types

A tuple is an array with a fixed number of elements, where each element can have a different type. Tuples are useful for fixed structures where each position has a specific type

// Tuple with two values: string and number
let user: [string, number] = ["Alice", 25];
console.log(user[0]); // "Alice"
console.log(user[1]); // 25


// Second value is optional
let person: [string, number?] = ["Bob"];
console.log(person[0]); // "Bob"
console.log(person[1]); // undefined


// Prevents modifications
const coordinates: readonly [number, number] = [10, 20];
// coordinates[0] = 30; // ❌ Error: Readonly tuple

CSS Properties

The React.CSSProperties type allows defining inline styles with type safety.

// CSS properties accepts a valid CSS property name and a valid CSS value
type ButtonProps = {
    style: React.CSSProperties;
};

// usage
<Button style={{ backgroundColor: "blue", padding: "10px" }} />

Record Type

The Record<K, V> type maps a set of keys to specific value types.

type ButtonProps = {
    borderRadius: Record<string, number>;
};

// ...
return (
    <Button 
        borderRadius={{
            topLeft: 10,
            topRight: 20,
            bottomLeft: 30,
            bottomRight: 40
        }}
    />
)

Function Types

Function without arguments and without return value:

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

<Button onClick={() => console.log("clicked")} />

Function with arguments and without return value:

type ButtonProps = {
    onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
};

<Button onClick={(event) => console.log(event)} />

React Children

The React.ReactNode type ensures that a component can accept valid React children.

type ButtonProps = {
    children: React.ReactNode;
};

<Button>Click me</Button>

Attributes and Native Element Wrapping

When wrapping a native HTML element, React.ComponentPropsWithoutRef<"button"> ensures that all standard button attributes are correctly typed.

type ButtonProps = React.ComponentPropsWithoutRef<"button">;

export default function Button({ ...props }: ButtonProps) {
    return <button {...props}>Click me</button>;
}

Ref Forwarding

When handling a ref, React.ComponentPropsWithRef<"button"> allows passing a ref to a native button.

type ButtonProps = React.ComponentPropsWithRef<"button">;

export default function Button({ ref, ...props }: ButtonProps) {
    return <button ref={ref} {...props}>Click me</button>;
}

Intersection Types

Intersection types allow combining multiple types into a single type definition. This is useful when extending native attributes while adding custom props.

type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
    variant: "primary" | "secondary";
};

export default function Button({ variant, ...props }: ButtonProps) {
    return <button {...props}>Click me</button>;
}

By using these TypeScript types effectively, React components become more type-safe, maintainable, and scalable.

JSX.Element vs React.ReactNode

Understanding the difference between JSX.Element and React.ReactNode is essential when defining types for React components. While they may seem similar, they serve different purposes.

React.ReactNode

React.ReactNode represents anything that React can render, including:

This makes React.ReactNode a more flexible type when defining component props that accept children.

const mixedContent: React.ReactNode = [
  "Some text",    // ✅ String is a ReactNode
  null,           // ✅ Null is a valid ReactNode
  false,          // ✅ Boolean is a valid ReactNode
  undefined,      // ✅ Undefined is a valid ReactNode
  <p key="1">Hello</p>, // ✅ JSX.Element is a ReactNode
];

JSX.Element

JSX.Element is a subset of React.ReactNode. It always returns an object containing type, props, and key, because JSX is compiled into React.createElement.

const element: JSX.Element = <h1>Hello, World!</h1>; // ✅ Valid JSX.Element

This JSX is compiled to:

const element = React.createElement("h1", null, "Hello, World!");

Since JSX.Element strictly represents a single JSX element, it is not as flexible as React.ReactNode.

Example Usage

Using JSX.Element when rendering a component that returns a single element:

const article: JSX.Element = (
  <article>
    <header>My Article</header>
    <p>This is some text.</p>
    <img src="image.jpg" alt="An example" />
  </article>
);

Key Differences

TypeCan ContainUse Case
React.ReactNodeJSX elements, strings, numbers, arrays, null, undefined, falseUse for props like children where multiple types are expected.
JSX.ElementA single JSX element (converted to React.createElement)Use when a function/component must return a single JSX element.

Choosing between React.ReactNode and JSX.Element depends on the use case. React.ReactNode is more flexible and should be used when handling component children, while JSX.Element is useful when explicitly enforcing that a function returns a React element.

useState in TypeScript

The useState hook in React allows managing state within a functional component. When using TypeScript, it's important to properly type state values for better type safety.

Passing a State Setter as a Prop

When passing a state updater function to a child component, TypeScript provides the React.Dispatch<React.SetStateAction<T>> type.

type ButtonProps = {
    setCount: React.Dispatch<React.SetStateAction<number>>;
};

// The `setCount` function is received from the parent component and updates the state.
const Button = ({ setCount }: ButtonProps) => {
    return <button onClick={() => setCount(count + 1)}>Click me</button>;
};

Inferring Types with useState

For primitive values, TypeScript can automatically infer the type, but you can explicitly specify it if needed.

export default function Button() {
    // TypeScript infers the type as `number`
    const [count, setCount] = useState(0); 

    // Explicitly specifying the type
    const [count, setCount] = useState<number>(0);

    return <Button setCount={setCount} />;
}

Since 0 is a number, TypeScript correctly infers that count should be of type number, making explicit typing optional in this case.

Typing State with Objects

When using objects as state values, TypeScript cannot infer the type automatically, so it must be explicitly defined.

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

export default function Button() {
    // Defining the state type inline
    const [user, setUser] = useState<{ name: string; age: number } | null>(null);

    // Or using a predefined type alias
    const [user, setUser] = useState<User | null>(null);

    // User can be null so we need to use optional chaining to prevent runtime errors
    const userName = user?.name;

    return <Button setUser={setUser} />;
}

Key Takeaways:

By properly typing state, we ensure better type safety and prevent common runtime errors in React applications using TypeScript.

useRef in TypeScript

The useRef hook in React is used to create mutable references to DOM elements or values that persist across renders without causing re-renders.

Typing useRef for DOM Elements

When using useRef to reference a DOM element, TypeScript needs to know the type of the element being referenced. In the case of an <input>, we specify HTMLInputElement as the type.

export default function Button() {
    const inputRef = useRef<HTMLInputElement>(null);

    return <input ref={inputRef} />;
}

Explanation:

Example: Using useRef to Focus an Input

export default function Button() {
    const inputRef = useRef<HTMLInputElement>(null);

    const handleClick = () => {
        // Using optional chaining to avoid errors if the ref is null
        inputRef.current?.focus();
    };

    return (
        <>
            <input ref={inputRef} />
            <button onClick={handleClick}>Focus Input</button>
        </>
    );
}

Key Takeaways:

By using useRef correctly, we can interact with DOM elements efficiently while maintaining type safety in TypeScript.

Custom Hooks in TypeScript

Custom hooks in React allow us to encapsulate reusable logic while leveraging TypeScript's strong type safety. Below are two commonly used custom hooks: useLocalStorage and useDebounce.

useLocalStorage: Persisting State in Local Storage

The useLocalStorage hook manages state while persisting it in the browser's localStorage.

export function useLocalStorage<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] {
    const [value, setValue] = useState<T>(() => {
        // returning a function inside useState only runs once at the first render - 
        // even if the component is re-rendered this function will not run again.
        return JSON.parse(localStorage.getItem(key) || JSON.stringify(initialValue));
    });

    useEffect(() => {
        localStorage.setItem(key, JSON.stringify(value));
    }, [value]);

    return [value, setValue];
}

Explanation:

const [theme, setTheme] = useLocalStorage<string>("theme", "light");

<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
    Toggle Theme
</button>

useDebounce: Delaying Rapid Updates

The useDebounce hook prevents excessive re-renders by delaying updates until the specified delay period has passed.

export function useDebounce<T>(value: T, delay = 500): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        const handler = setTimeout(() => setDebouncedValue(value), delay);
        return () => clearTimeout(handler);
    }, [value, delay]);

    return debouncedValue;
}

Explanation:

const searchTerm = useDebounce<string>(inputValue, 300);

useEffect(() => {
    if (searchTerm) {
        fetchResults(searchTerm);
    }
}, [searchTerm]);

Key Takeaways:

Custom hooks like these enhance React applications by encapsulating logic in a reusable and scalable way.

type vs interface in TypeScript

Both type and interface in TypeScript are used to define the shape of objects, but they have key differences in how they behave and can be used.

1️⃣ Interfaces Can Be Extended and Merge Automatically

Interfaces allow for extension using the extends keyword and automatically merge when redeclared.

interface Person {
    name: string;
}

// Extending an interface
interface Employee extends Person {
    role: string;
}

// Merging: This will merge with `Person` if re-declared elsewhere
interface Person {
    age: number;
}

const employee: Employee = {
    name: "Alice",
    role: "Developer",
    age: 30, // ✅ Merged from Person
};

Key Takeaways:

2️⃣ Types Do NOT Merge Automatically but Support Intersections (&)

Unlike interfaces, types do not merge when redefined. Instead, they support intersection types using &.

type User = {
    name: string;
};

// Extending a type using intersection
type Admin = User & {
    permissions: string[];
};

const admin: Admin = {
    name: "Bob",
    permissions: ["READ", "WRITE"],
};

Key Takeaways:

3️⃣ Types Support Union Types, Interfaces Do Not

type can define union types, allowing multiple possible values. Interfaces do not support unions.

type ID = string | number; // ✅ Union type possible

const userId1: ID = 123;     // ✅ Valid
const userId2: ID = "abc123"; // ✅ Valid

// ❌ Interfaces do not support union types
// interface ID = string | number; // ❌ Error

Key Takeaways:

4️⃣ Types Work with Tuples, Interfaces Do Not

type can define tuples, but interfaces cannot.

type Point = [number, number];

const coordinates: Point = [10, 20]; // ✅ Valid tuple

// ❌ Interfaces cannot directly represent tuples
// interface Point = [number, number]; // ❌ Error

Key Takeaways:

Which One Should You Use?

Since type is more versatile (supports intersections, unions, and tuples) and does not introduce automatic merging, I only use type aliases for all my types, as my standard or default type.

However, interfaces can still be useful when working with class-based programming or when leveraging automatic merging behavior.

Best Practice:

as in TypeScript

The as keyword in TypeScript is used for type assertions, allowing developers to explicitly specify a type when TypeScript cannot automatically infer it.

as const for Immutable Values

Using as const makes an array or object fully immutable, treating its values as literal types instead of general types like string or number.

const buttonTextOptions = ['Click me', 'Submit', 'Login'] as const;

What happens with as const?

// ❌ Error: Cannot assign to index 0 because it is a read-only property.
buttonTextOptions[0] = 'Click here'; 

Example Usage:

Useful when defining a set of options that should remain constant, such as button labels in a dropdown.

as Type for Type Assertions

as can also be used for type casting when retrieving data that TypeScript cannot infer correctly.

type ButtonColor = 'primary' | 'secondary' | 'tertiary';

export default function Button() {
    useEffect(() => {
        // TypeScript does not know the expected type from localStorage, so we assert it.
        const previousColor = localStorage.getItem('buttonColor') as ButtonColor;
    }, []);

    return <></>;
}

What happens here?

Use type assertions carefully—they override TypeScript's checks and can introduce hidden bugs.

Omit in TypeScript

The Omit<T, K> utility type removes a specific key from an object type.

Example: Removing a Property from an Object Type

type User = {
    sessionId: string;
    name: string;
    email: string;
    role: string;
};

type Guest = Omit<User, 'sessionId'>;

What happens here?

type Guest = {
    name: string;
    email: string;
    role: string;
};

When to Use Omit?

Key Takeaways

These features help maintain type safety and cleaner code in TypeScript applications.

Generic Types in TypeScript

Generics in TypeScript allow functions, components, and types to be reusable while maintaining type safety. They enable us to define a placeholder type (T) that gets replaced with a specific type when the function or component is used.

Generic Functions

Generic functions allow us to write functions that work with different types while keeping type safety.

Using an Arrow Function

When using an arrow function, a comma (<T,>) is required after the generic type to avoid parsing issues.

const convertToArray = <T,>(value: T): T[] => {
    return [value];
};

Using a Function Declaration

A generic function can also be declared with the traditional function syntax.

function convertToArray<T>(value: T): T[] {
    return [value];
}

Example Usage

The function can be used with different types, and TypeScript will infer the type automatically.

convertToArray('hello'); // Returns ['hello'], inferred as string[]
convertToArray(123);     // Returns [123], inferred as number[]

Generic Props in React Components

Generics can be used to create flexible component props that can accept various types.

Using a Generic Type for Props

We can define a ButtonProps type that takes a generic parameter T.

type ButtonProps<T> = {
    countValue: T;
    countHistory: T[];
};

Using Generics in a Functional Component

The <T> notation is used to specify a generic type for the component.

export default function Button<T>({ countValue, countHistory }: ButtonProps<T>) {
    return <button>Click me</button>;
}

Inline Definition of Generic Props

Instead of defining a separate ButtonProps type, we can declare the props directly in the function signature.

export default function Button<T>({ countValue, countHistory }: {
    countValue: T;
    countHistory: T[];
}) {
    return <button>Click me</button>;
}

Key Takeaways

Using generics in TypeScript ensures flexibility while maintaining type safety, making code more scalable and maintainable.

File Naming Conventions in TypeScript

1️⃣ index.d.ts – Declaration Files

Used for defining types in third-party libraries without TypeScript support. Contains only type definitions, no implementation.

// index.d.ts
declare module "some-library" {
    export function someFunction(value: string): number;
}

2️⃣ types.ts – Project Types

Stores custom types for the project, keeping the code organized and imports clean.

// types.ts
export type ButtonProps = {
    label: string;
    onClick: () => void;
};

// usage
import { type ButtonProps } from './types';

Key Takeaways

unknown in TypeScript

The unknown type is useful when dealing with dynamic or external data where the type is not immediately known.

Using unknown for API Responses

When fetching data, we cannot be sure of its structure, so we use unknown instead of any for better type safety.

export default function Button() {
    useEffect(() => {
        fetch('https://api.example.com/data')
            .then(response => response.json())
            .then((data: unknown) => {
                // Data is unknown, ensuring type validation before use
                // Example: Use Zod to validate and parse the data
                // const todo = todoSchema.parse(data);
                console.log(data);
            })
            .catch(error => console.error('Error fetching data:', error));
    }, []);

    return <></>;
}

Why Use unknown Instead of any?

Key Takeaways

Useful TypeScript Resources

1️⃣ ts-reset

🔗 GitHub: mattpocock/ts-reset

2️⃣ DefinitelyTyped

🔗 GitHub: DefinitelyTyped/DefinitelyTyped

Key Takeaways