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:
- Verbose & Unnecessary: TypeScript’s type inference often makes React.FC redundant, as TypeScript can automatically infer types from function arguments.
- Lack of Type Safety: There is no strict type checking for props, which can lead to potential issues.
- Inconvenient: It requires repetitive prop definitions, making the code more cluttered.
- Includes children by Default: Even if a component does not explicitly define children as a prop, it will still be included because of how React.FC is defined internally in React's type definitions.
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:
- Concise & Type-Safe: Uses TypeScript’s native type definitions without unnecessary abstractions.
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:
- JSX elements
- Strings
- Numbers
- Arrays
- null
- undefined
- false
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
Type | Can Contain | Use Case |
---|---|---|
React.ReactNode | JSX elements, strings, numbers, arrays, null, undefined, false | Use for props like children where multiple types are expected. |
JSX.Element | A 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:
- For primitive values (like numbers, strings, booleans), TypeScript can infer the type.
- For objects, TypeScript requires explicit typing.
- Use | null when the state might be initially null.
- Use optional chaining (?.) to safely access properties of potentially null or undefined objects.
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:
- useRef<HTMLInputElement>(null) initializes the reference with null, ensuring that TypeScript recognizes that it will later hold an HTMLInputElement.
- This reference can be used to access the input element's properties or methods, such as .focus().
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:
- useRef is commonly used to reference DOM elements like input, button, or div.
- The generic type (<HTMLInputElement>) ensures proper type safety when accessing the element.
- Since refs are mutable, updates to them do not trigger a re-render.
- Optional chaining (?.) is used to prevent runtime errors when accessing the ref before it is assigned.
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:
- Uses a generic type <T> to allow flexibility for any data type (string, number, object, etc.).
- The initial state is retrieved from localStorage, falling back to initialValue if no value exists.
- Updates the localStorage whenever value changes.
- Returns the state value and the setter function, just like useState.
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:
- Uses a generic type <T> to handle different types of values.
- When value changes, a timeout is set to update debouncedValue after the specified delay.
- If value changes before the delay expires, the previous timeout is cleared, preventing unnecessary updates.
const searchTerm = useDebounce<string>(inputValue, 300); useEffect(() => { if (searchTerm) { fetchResults(searchTerm); } }, [searchTerm]);
Key Takeaways:
- Generic types (<T>) make hooks reusable for multiple data types.
- useLocalStorage synchronizes state with localStorage, ensuring data persistence.
- useDebounce optimizes performance by reducing unnecessary updates.
- Strong TypeScript typing ensures type safety and prevents runtime errors.
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:
- Interfaces merge automatically when re-declared, adding new properties.
- Supports extension using extends, making it useful for object-oriented patterns.
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:
- Types do not merge automatically—redeclaring a type with the same name will cause an error.
- Supports intersections (&) for combining multiple types.
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:
- Use type when defining union types (e.g., string | number).
- Interfaces do not support union types.
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:
- Use type when defining tuples.
- Interfaces cannot represent tuples directly.
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:
- Use type for most cases, especially for unions, intersections, and tuples.
- Use interface only when working with class-based structures or when automatic merging is desired.
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?
- The type of buttonTextOptions becomes a tuple:
readonly ['Click me', 'Submit', 'Login']
- The array elements are now read-only and cannot be modified.
// ❌ 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?
- localStorage.getItem() always returns a string | null, but we assert it as ButtonColor.
- If buttonColor is missing or an invalid value is stored, this can lead to potential runtime errors.
⚠ 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?
- Guest type inherits all properties from User except for sessionId.
- The resulting type is:
type Guest = { name: string; email: string; role: string; };
When to Use Omit?
- When you need to exclude sensitive data (e.g., sessionId, password).
- When you create variant types (e.g., a Guest user vs. a logged-in User).
- When modifying types dynamically without redefining them.
Key Takeaways
- as const makes arrays/objects immutable and treats values as literals.
- as Type is used for type assertions, overriding TypeScript’s type checking (use with caution).
- Omit<T, K> creates a new type by removing specific properties from an existing type.
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
- Generics provide reusability by allowing types to be dynamically assigned.
- Functions can be generic, making them work with multiple data types.
- Components can use generics, making props more flexible.
- TypeScript infers types automatically, reducing unnecessary explicit type definitions.
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
- index.d.ts → For third-party types.
- types.ts → For project-wide type definitions.
- Explicit imports (import { type ... }) improve clarity.
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?
- unknown forces type checking before usage, unlike any, which allows unrestricted operations.
- Safer for API responses, ensuring data is validated before being used.
Key Takeaways
- Use unknown when the type is not immediately known.
- Validate unknown data before using it (e.g., with Zod or type assertions).
- Avoid any unless absolutely necessary.
Useful TypeScript Resources
1️⃣ ts-reset
- A TypeScript reset library that improves default TypeScript behavior.
- Provides stricter types for built-in JavaScript functions.
- Helps catch edge cases and unexpected behaviors.
2️⃣ DefinitelyTyped
🔗 GitHub: DefinitelyTyped/DefinitelyTyped
- A massive repository of TypeScript type definitions for third-party libraries.
- Includes types for libraries that don't provide their own TypeScript support.
- Installed via @types packages, e.g., npm install @types/lodash.
Key Takeaways
- Use ts-reset to enforce stricter and safer TypeScript defaults.
- Check DefinitelyTyped when using JavaScript libraries without native TypeScript support.