DEV Community

Cover image for React with TypeScript: Advanced Techniques (Compatible with React 19) 🚀
Abdulnasır Olcan
Abdulnasır Olcan

Posted on

React with TypeScript: Advanced Techniques (Compatible with React 19) 🚀

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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" />
  </>
);

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>;
}

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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! 🚀

Top comments (0)