Using TypeScript with React enhances code quality while minimizing the chance of errors. However, effectively leveraging the full power of TypeScript in React projects can sometimes be complex. In this article, we will dive deep into topics such as component types, state management, refs, and event handling in React projects with TypeScript, keeping in mind the new features of React 19. My goal is to help you build a clean, sustainable, and scalable React codebase with comprehensive examples and explanations.
a. Introduction: Transitioning to React with TypeScript
At first, TypeScript’s complex solutions might seem intimidating. However, as you incorporate TypeScript into your daily development flow, you will grow to appreciate it. The biggest challenge lies in integrating TypeScript into React’s various patterns and features in the most efficient way. This article aims to help you overcome these challenges and provide the most elegant solutions to enhance your existing React codebase.
The article is structured into components, state management, refs, and event handling.
b. Component Types and Props Management
React components form the core of an application and are among the most commonly written structures. With the introduction of Hooks, most components are now simple JavaScript functions that take props and return JSX. Therefore, typing components correctly becomes quite straightforward. The only restriction is that the component always takes a single argument, which is an object of properties.
1. Basic Props
First, let’s look at some basic prop structures. In this example, we have a component that accepts title
and an optional description
as props.
type Props = {
title: string;
description?: string;
};
function ProductTile({ title, description }: Props) {
return (
<div>
<div>{title}</div>
{description && <div>{description}</div>}
</div>
);
}
In this example, description
is defined as optional, allowing the component to work both with and without the description
.
2. Child Components (Children)
Passing child components (children) to a component is a common use case. In such cases, you can simplify your props definition by using the PropsWithChildren
type.
import { PropsWithChildren } from 'react';
type Props = {
title: string;
};
function ProductTile({ title, children }: PropsWithChildren<Props>) {
return (
<div>
<div>{title}</div>
{children}
</div>
);
}
PropsWithChildren
automatically adds children
to your existing props type, reducing redundancy. If your component only takes children
, you can use PropsWithChildren
by itself.
3. Piping and Spreading Props
Sometimes, you may want to pass props down to inner components. In such cases, you can avoid repetition by using the ComponentProps
type.
import { ComponentProps } from 'react';
import ProductTile from './ProductTile';
type ProductTileProps = ComponentProps<typeof ProductTile>;
type Props = {
color: string;
} & ProductTileProps;
function ProminentProductTile({ color, ...props }: Props) {
return (
<div style={{ background: color }}>
<ProductTile {...props} />
</div>
);
}
This method allows you to automatically inherit props using ComponentProps
instead of manually repeating them. This is particularly useful when working with complex prop structures.
4. Typing HTML Elements
The same pattern can be applied to HTML elements. For example, if you want to pass all extra props to a button
element:
import { ComponentProps } from 'react';
type ButtonProps = {
color: string;
} & ComponentProps<'button'>;
function CustomButton({ color, ...props }: ButtonProps) {
return (
<button style={{ background: color }} {...props} />
);
}
This approach allows you to easily pass props to HTML elements while maintaining type safety.
5. Specific Components & Defining Component Types with the typeof
Operator
To define a component’s type, using the typeof
operator to determine the props of a component provides a simpler and more error-free structure. For example:
Reference: Typeof Type Operator
import { ComponentProps } from 'react';
import Icon from './Icon';
type Props = {
icon?: typeof Icon;
} & ComponentProps<'button'>;
function Button({ icon: IconComponent, children, ...props }: Props) {
return (
<button {...props}>
{icon && <IconComponent size={24} />}
{children}
</button>
);
}
In this example, the Icon
component is typed using typeof
, and ComponentProps
is used to inherit the props of a button
element. This approach provides a more flexible and safe structure.
6. Inferring Props: Flexibility with Generic Components
When creating a component, we often determine the component type dynamically using the as
or component
prop. This allows the component to accept props appropriate to the HTML element or component it’s being transformed into.
Reference: Passing Any Component as a Prop and Inferring Its Props
type BoxProps<T extends keyof JSX.IntrinsicElements> = {
as: T;
} & JSX.IntrinsicElements[T];
function Box<T extends keyof JSX.IntrinsicElements>({ as, ...props }: BoxProps<T>) {
const Component = as;
return <Component {...props} />;
}
// Usage example:
const App = () => (
<>
{/* value doesn't exist on div, this will throw an error */}
<Box as="div" value="foo" />
{/* value exists on input, this will work */}
<Box as="input" value="foo" />
</>
);
In this example, the Box
component can dynamically transform into different HTML elements and correctly accept the corresponding props. By using JSX.IntrinsicElements
, you can define the prop types based on the element.
7. Passing Components (Using ComponentType
)
ComponentType
is a powerful way to pass components as props and render them. This type is especially useful in dependency injection scenarios, such as the render-prop pattern.
Reference: Calling a render prop to customize rendering
import { ComponentType } from 'react';
type ProductTileProps = {
title: string;
description?: string;
};
type Props = {
render: ComponentType<ProductTileProps>;
};
function ProductTile({ render }: Props) {
const props: ProductTileProps = { title: "Product", description: "Description" };
return render(props);
}
In this example, the ProductTile
component takes another component as a render prop, which is determined using ComponentType
, and passes ProductTileProps
to it. This provides great flexibility for dynamic component switching and rendering.
8. Usage in Third-Party Components
This pattern is also highly useful when typing and passing components from third-party libraries or through .d.ts
files. For instance, you can import components from an external library like this:
declare module 'some-lib' {
type Props = {
title: string;
description?: string;
};
export const ProductTile: ComponentType<Props>;
}
9 JSX and Passing Components
Sometimes, you may want to customize content by passing raw JSX. The ReactNode
type is useful for this purpose.
import { PropsWithChildren, ReactNode } from 'react';
type LayoutProps = {
title: string;
sidebar: ReactNode;
} & PropsWithChildren;
function Layout({ title, children, sidebar }: LayoutProps) {
return (
<>
<div className="sidebar">{sidebar}</div>
<main className="content">
<h1>{title}</h1>
{children}
</main>
</>
);
}
// Usage
const sidebarContent = (
<div>
<a href="/shoes">Shoes</a>
<a href="/watches">Watches</a>
<a href="/shirts">Shirts</a>
</div>
);
const App = (
<Layout title="Running Shoes" sidebar={sidebarContent}>
{/* Sayfa içeriği */}
</Layout>
);
ReactNode
provides a flexible way to pass raw JSX content to components.
c. State Management
React simplifies state management with hooks like useState
and useReducer
. Using these hooks with TypeScript ensures that the state and actions are properly typed.
1. Simple State Management with useState
useState
is ideal for simple states. The type is inferred from the initial value, but for more complex states, you may need to define types manually.
Reference: useState
import { useState } from 'react';
type AuthState = {
authenticated: boolean;
user?: {
firstname: string;
lastname: string;
};
};
type Todo = {
id: string;
title: string;
completed: boolean;
};
function TodoList() {
const [authState, setAuthState] = useState<AuthState>({ authenticated: false });
const [todos, setTodos] = useState<Array<Todo> | null>(null);
// Component logic
}
In this example, manual type definitions are provided for authState
and todos
, ensuring that TypeScript correctly infers the types.
2. Complex State Management with useReducer
For more complex state updates, useReducer
is more suitable. Typing the reducer function and actions correctly with TypeScript ensures safe and maintainable state management.
Reference: useReducer
import { useReducer } from 'react';
type State = {
count: number;
error?: string;
};
type Action =
| { type: 'increment'; payload?: number }
| { type: 'decrement'; payload?: number }
| { type: 'reset' }
| { type: 'error'; message: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + (action.payload || 1) };
case 'decrement':
return { ...state, count: state.count - (action.payload || 1) };
case 'reset':
return { count: 0 };
case 'error':
return { ...state, error: action.message };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
<button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
{state.error && <p>Error: {state.error}</p>}
</div>
);
}
In this example, while managing complex state logic with useReducer
, action types are safeguarded using TypeScript.
d. Using Refs
Refs provide direct access to DOM elements or React components. When using refs with TypeScript, defining the correct types helps prevent errors.
1. Creating a Ref with useRef
useRef
is a common way to create refs in functional components. It is initialized with a null
value during the first render, and the ref type should be defined correctly.
Reference: useRef
import { useRef, ComponentProps } from 'react'
type ButtonProps = ComponentProps<'button'>;
function Button(props: ButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
return <button ref={ref} {...props} />;
}
In this example, the ref created with useRef
is typed as an HTMLButtonElement
.
2. Forwarding Refs
With React 19, ref forwarding becomes automatic, but forwardRef
is still useful in specific scenarios. However, for a simpler usage, you can directly define ref types.
Reference: forwardRef
import { forwardRef, ComponentProps } from 'react'
import Icon from './Icon';
type ButtonProps = {
icon?: typeof Icon;
} & ComponentProps<'button'>;
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ icon: IconComponent, children, ...props }, ref) => (
<button ref={ref} {...props}>
{IconComponent && <IconComponent size={24} />}
{children}
</button>
)
);
export default Button;
In this example, forwardRef
is used to ensure that the ref is correctly forwarded. While ref forwarding has been simplified in React 19, the importance of forwardRef
may diminish.
3. Passing Refs to Other Components
Sometimes, instead of directly forwarding refs, you may want to pass them to other components. In such cases, the RefObject
type is used.
import React, { RefObject } from 'react';
type PopoverProps = {
anchor: RefObject<HTMLElement>;
};
function Popover({ anchor }: PopoverProps) {
// Positioning logic based on the ref
return <div>Popover Content</div>;
}
In this example, the Popover
component can be positioned based on an external ref, allowing it to align with a specific HTML element.
e. Event Handling
Typing events correctly in React ensures that applications are more robust. It’s important to use the right types when managing both React's own Synthetic Event system and native events.
Reference: React event object
1. React and Native Event Types
React uses its own Synthetic Event system while also supporting native JavaScript events. Understanding the difference between these two types of events is key to using the correct types:
- React Synthetic Event: Events wrapped by React, providing browser independence.
- Native Event: Events coming directly from the browser, used outside of React’s Synthetic Event system.
2. Passing Event Listeners as Props
Passing event listeners as props is a common practice in React components. Using the correct event types with TypeScript ensures that your code is safe and error-free.
import { MouseEventHandler, ComponentProps } from 'react'
type ButtonProps = {
onClick: MouseEventHandler<HTMLButtonElement>;
} & ComponentProps<'button'>;
function Button({ onClick, ...props }: ButtonProps) {
return <button onClick={onClick} {...props} />;
}
export default Button;
In this example, the onClick
event listener is correctly typed.
3. Form Events and Typing with React.FormEvent
When handling the onSubmit
event during form submission, the event type is defined as React.FormEvent<HTMLFormElement>
. This ensures proper typing of the event and allows you to safely apply actions like preventDefault
.
import * as React from 'react';
function LoginForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Login operations will go here
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="username" />
<input type="password" name="password" />
<button type="submit">Log In</button>
</form>
);
}
In this example, the onSubmit
function is typed with React.FormEvent
, allowing you to safely use preventDefault
and other features. This ensures the form works correctly and allows you to handle form events safely.
4. Effectively Adding Event Listeners
You can use useEffect
to add native event listeners. In such cases, it's important to use native event types.
import React, { useEffect } from 'react';
function Navigation() {
useEffect(() => {
const handleScroll = (e: Event) => {
console.log('Scrolled:', e);
};
document.addEventListener('scroll', handleScroll);
return () => {
document.removeEventListener('scroll', handleScroll);
};
}, []);
return <nav>Navigation Bar</nav>;
}
export default Navigation;
In this example, a native scroll event listener has been added, and the correct typing has been applied.
Conclusion
Using TypeScript with React makes your development process more secure and sustainable. By correctly typing component types, state management, refs, and event handling, you reduce the likelihood of errors and make your code more readable. The advanced techniques and examples we've covered in this article will help you develop React applications more cleanly and efficiently.
With the innovations brought by React 19, such as automatic ref forwarding and improved hooks support, combined with TypeScript, you can build powerful and flexible applications. By fully utilizing the robust type system that TypeScript offers, you can make your projects more scalable and maintainable.
Remember, by using TypeScript and React together, you can improve both your developer experience and the quality of the applications you deliver to end users.
Happy coding! 🚀