Steve Kinney

React-Specific TypeScript Patterns

TypeScript and React were made for each other, but there are specific patterns and conventions that can make or break your development experience. This guide covers the essential React-specific TypeScript patterns that every React developer should master.

React 19 TypeScript Improvements

React 19 brings cleaner TypeScript patterns that reduce boilerplate and improve type inference. These improvements make React development more intuitive.

Simplified Component Typing

React 19 embraces simpler component patterns, moving away from verbose type annotations:

// ✅ React 19: Clean and simple
function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

// Still works, but often unnecessary
const UserCard: React.FC<{ user: User }> = ({ user }) => {
  return <div>{user.name}</div>;
};

Improved Ref Type Inference

React 19’s ref system works more naturally with TypeScript:

// ✅ React 19: Refs just work
function TextInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focusInput = () => {
    inputRef.current?.focus(); // TypeScript knows this might be null
  };

  return <input ref={inputRef} type="text" />;
}

// Cleaner forwardRef patterns
const FancyInput = forwardRef<HTMLInputElement, { placeholder: string }>(
  ({ placeholder }, ref) => {
    return <input ref={ref} placeholder={placeholder} />;
  }
);
FancyInput.displayName = 'FancyInput'; // TypeScript helps remind you

Better Error Messages

React 19 with TypeScript 5+ provides more helpful error messages for common mistakes:

// If you forget to pass required props:
<UserCard /> // Error: Property 'user' is missing in type '{}'

// If you pass wrong prop types:
<UserCard user="string" /> // Error: Type 'string' is not assignable to type 'User'

// If you return wrong type from component:
function BadComponent(): number {
  return 42; // Error: 'number' is not assignable to 'ReactNode'
}

Component Type Declarations: FC vs Function Declarations

One of the first decisions you’ll face is how to type your React components. Let’s understand the trade-offs.

React.FC: The Traditional Approach

import { FC, ReactNode } from 'react';

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
}

// Using React.FC
const Button: FC<ButtonProps> = ({ variant = 'primary', onClick, children }) => {
  return (
    <button className={`btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
};

// What FC gives you:
// ✅ children prop is automatically included
// ✅ Return type is enforced as ReactElement | null
// ✅ displayName, defaultProps, propTypes are typed

Function Declarations: The Modern Approach

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
  children: ReactNode; // Explicitly define children
}

// Using function declaration
function Button({ variant = 'primary', onClick, children }: ButtonProps) {
  return (
    <button className={`btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
}

// Or arrow function without FC
const Button = ({ variant = 'primary', onClick, children }: ButtonProps) => {
  return (
    <button className={`btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
};

Which Should You Use?

// ✅ Prefer function declarations or typed arrow functions
// More explicit, better inference, no hidden behavior
function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      {children}
    </div>
  );
}

// ❌ Avoid FC unless you specifically need its features
// Adds overhead, implicit children, less flexible
const Card: FC<CardProps> = ({ title, children }) => {
  // ...
};

The Children Prop: Getting It Right

Understanding how to type children is crucial for component composition.

Different Children Types

import { ReactNode, ReactElement, JSX } from 'react';

// ReactNode: Most flexible, accepts everything
interface ContainerProps {
  children: ReactNode; // string | number | boolean | null | undefined | ReactElement | ReactFragment | ReactPortal
}

// ReactElement: Only JSX elements
interface WrapperProps {
  children: ReactElement; // Must be a JSX element
}

// Specific element types
interface ListProps {
  children: ReactElement<HTMLLIElement> | ReactElement<HTMLLIElement>[];
}

// Function as children (render prop)
interface RenderProps<T> {
  children: (data: T) => ReactNode;
}

// Optional children
interface CardProps {
  title: string;
  children?: ReactNode;
}

// No children allowed
interface IconProps {
  name: string;
  children?: never; // Explicitly disallow children
}

Constraining Children

// Only allow specific components as children
interface TabsProps {
  children: ReactElement<TabProps> | ReactElement<TabProps>[];
}

interface TabProps {
  label: string;
  children: ReactNode;
}

function Tabs({ children }: TabsProps) {
  // TypeScript ensures only Tab components are passed
  const tabs = React.Children.toArray(children) as ReactElement<TabProps>[];

  return (
    <div className="tabs">
      {tabs.map((tab, index) => (
        <div key={index}>{tab}</div>
      ))}
    </div>
  );
}

// Usage
<Tabs>
  <Tab label="First">Content 1</Tab>
  <Tab label="Second">Content 2</Tab>
  {/* <div>Not allowed!</div> */} {/* ❌ Type error */}
</Tabs>

Ref Forwarding with TypeScript

Refs are tricky in TypeScript. Here’s how to handle them properly.

Basic Ref Forwarding

import { forwardRef, InputHTMLAttributes } from 'react';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...props} />
      </div>
    );
  }
);

// Must add display name for debugging
Input.displayName = 'Input';

// Usage
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  return <Input ref={inputRef} label="Name" />;
}

Generic Ref Forwarding

// Generic component with ref forwarding
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => ReactNode;
}

// Create a typed forwardRef helper
function typedForwardRef<T, P = {}>(
  render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
) {
  return forwardRef<T, P>(render);
}

const List = typedForwardRef<HTMLUListElement, ListProps<any>>(
  ({ items, renderItem }, ref) => {
    return (
      <ul ref={ref}>
        {items.map((item, index) => (
          <li key={index}>{renderItem(item)}</li>
        ))}
      </ul>
    );
  }
);

Imperative Handle Pattern

import { forwardRef, useImperativeHandle, useRef } from 'react';

interface ModalHandle {
  open: () => void;
  close: () => void;
}

interface ModalProps {
  title: string;
  children: ReactNode;
}

const Modal = forwardRef<ModalHandle, ModalProps>(
  ({ title, children }, ref) => {
    const [isOpen, setIsOpen] = useState(false);

    useImperativeHandle(ref, () => ({
      open: () => setIsOpen(true),
      close: () => setIsOpen(false),
    }), []);

    if (!isOpen) return null;

    return (
      <div className="modal">
        <h2>{title}</h2>
        {children}
      </div>
    );
  }
);

// Usage
function App() {
  const modalRef = useRef<ModalHandle>(null);

  return (
    <>
      <button onClick={() => modalRef.current?.open()}>
        Open Modal
      </button>
      <Modal ref={modalRef} title="Example">
        Modal content
      </Modal>
    </>
  );
}

React.memo with TypeScript

Optimizing components with memo requires proper typing.

Basic Memo Usage

interface ExpensiveListProps {
  items: string[];
  onItemClick: (item: string) => void;
}

const ExpensiveList = memo<ExpensiveListProps>(({ items, onItemClick }) => {
  console.log('ExpensiveList rendered');

  return (
    <ul>
      {items.map(item => (
        <li key={item} onClick={() => onItemClick(item)}>
          {item}
        </li>
      ))}
    </ul>
  );
});

// With custom comparison
const ExpensiveList = memo<ExpensiveListProps>(
  ({ items, onItemClick }) => {
    // Component implementation
  },
  (prevProps, nextProps) => {
    // Return true if props are equal (skip re-render)
    return (
      prevProps.items.length === nextProps.items.length &&
      prevProps.items.every((item, index) => item === nextProps.items[index])
    );
  }
);

Memo with Generic Components

// Generic memoized component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => ReactNode;
  keyExtractor: (item: T) => string;
}

// Helper for generic memo
function typedMemo<T extends ComponentType<any>>(
  Component: T,
  propsAreEqual?: (
    prevProps: ComponentProps<T>,
    nextProps: ComponentProps<T>
  ) => boolean
): T {
  return memo(Component, propsAreEqual) as T;
}

function ListComponent<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

const MemoizedList = typedMemo(ListComponent);

Higher-Order Component Patterns

HOCs are complex to type but follow predictable patterns.

Basic HOC Pattern

// HOC that adds loading state
interface WithLoadingProps {
  loading: boolean;
}

function withLoading<P extends object>(
  Component: ComponentType<P>
): ComponentType<P & WithLoadingProps> {
  return ({ loading, ...props }: P & WithLoadingProps) => {
    if (loading) {
      return <div>Loading...</div>;
    }

    return <Component {...props as P} />;
  };
}

// Usage
interface UserProps {
  name: string;
  age: number;
}

function User({ name, age }: UserProps) {
  return <div>{name} is {age}</div>;
}

const UserWithLoading = withLoading(User);

// Now requires loading prop
<UserWithLoading name="Alice" age={30} loading={false} />

HOC with Injected Props

// HOC that injects props
interface WithAuthProps {
  user: { id: string; name: string } | null;
}

function withAuth<P extends WithAuthProps>(
  Component: ComponentType<P>
): ComponentType<Omit<P, keyof WithAuthProps>> {
  return (props: Omit<P, keyof WithAuthProps>) => {
    const user = useAuth(); // Custom hook

    return <Component {...props as P} user={user} />;
  };
}

// Component expects user prop
function Profile({ user }: WithAuthProps) {
  if (!user) return <div>Please log in</div>;
  return <div>Welcome, {user.name}</div>;
}

// Wrapped component doesn't need user prop
const ProfileWithAuth = withAuth(Profile);
<ProfileWithAuth /> // No user prop needed!

Event Handler Patterns

React events need special attention in TypeScript.

Typed Event Handlers

import { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent } from 'react';

interface FormProps {
  onSubmit: (data: FormData) => void;
}

function Form({ onSubmit }: FormProps) {
  // Inline event handlers with inferred types
  return (
    <form onSubmit={(e) => {
      // e is inferred as FormEvent<HTMLFormElement>
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      onSubmit(formData);
    }}>
      <input
        onChange={(e) => {
          // e is inferred as ChangeEvent<HTMLInputElement>
          console.log(e.target.value);
        }}
      />

      <button
        onClick={(e) => {
          // e is inferred as MouseEvent<HTMLButtonElement>
          e.stopPropagation();
        }}
      >
        Submit
      </button>
    </form>
  );
}

// Extracted event handlers
function SearchBar() {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    // Handle change
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      // Handle enter
    }
  };

  return (
    <input
      onChange={handleChange}
      onKeyDown={handleKeyDown}
    />
  );
}

Generic Event Handlers

// Generic click handler for any element
type ClickHandler<T = HTMLElement> = (
  event: MouseEvent<T>
) => void;

interface ClickableProps<T = HTMLElement> {
  onClick?: ClickHandler<T>;
  children: ReactNode;
}

function Clickable<T = HTMLElement>({
  onClick,
  children
}: ClickableProps<T>) {
  return (
    <div onClick={onClick as any}>
      {children}
    </div>
  );
}

// Usage with specific element types
<Clickable<HTMLButtonElement>
  onClick={(e) => {
    // e.currentTarget is HTMLButtonElement
    console.log(e.currentTarget.disabled);
  }}
>
  Click me
</Clickable>

Component Props Patterns

Extending HTML Element Props

import { ButtonHTMLAttributes, AnchorHTMLAttributes } from 'react';

// Button that extends native button props
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
}

function Button({
  variant = 'primary',
  size = 'medium',
  children,
  ...rest
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      {...rest}
    >
      {children}
    </button>
  );
}

// Polymorphic component (can be button or anchor)
type ButtonOrLinkProps =
  | (ButtonHTMLAttributes<HTMLButtonElement> & { as?: 'button' })
  | (AnchorHTMLAttributes<HTMLAnchorElement> & { as: 'a'; href: string });

function ButtonOrLink(props: ButtonOrLinkProps) {
  if (props.as === 'a') {
    return <a {...props} />;
  }

  return <button {...props} />;
}

Discriminated Union Props

// Component with mutually exclusive props
type AlertProps =
  | { type: 'success'; message: string }
  | { type: 'error'; message: string; retry?: () => void }
  | { type: 'info'; message: string; details?: string };

function Alert(props: AlertProps) {
  switch (props.type) {
    case 'success':
      return <div className="alert-success">{props.message}</div>;

    case 'error':
      return (
        <div className="alert-error">
          {props.message}
          {props.retry && (
            <button onClick={props.retry}>Retry</button>
          )}
        </div>
      );

    case 'info':
      return (
        <div className="alert-info">
          {props.message}
          {props.details && <p>{props.details}</p>}
        </div>
      );
  }
}

Compound Component Patterns

// Compound components with TypeScript
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined);

interface TabsProps {
  children: ReactNode;
  defaultTab?: string;
}

interface TabListProps {
  children: ReactElement<TabProps> | ReactElement<TabProps>[];
}

interface TabProps {
  value: string;
  children: ReactNode;
}

interface TabPanelProps {
  value: string;
  children: ReactNode;
}

// Main component
function Tabs({ children, defaultTab }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultTab || '');

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
}

// Sub-components
Tabs.List = function TabList({ children }: TabListProps) {
  const context = useContext(TabsContext);
  if (!context) throw new Error('TabList must be used within Tabs');

  return <div className="tab-list">{children}</div>;
};

Tabs.Tab = function Tab({ value, children }: TabProps) {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tab must be used within Tabs');

  return (
    <button
      className={context.activeTab === value ? 'active' : ''}
      onClick={() => context.setActiveTab(value)}
    >
      {children}
    </button>
  );
};

Tabs.Panel = function TabPanel({ value, children }: TabPanelProps) {
  const context = useContext(TabsContext);
  if (!context) throw new Error('TabPanel must be used within Tabs');

  if (context.activeTab !== value) return null;

  return <div className="tab-panel">{children}</div>;
};

// Usage with full type safety
<Tabs defaultTab="tab1">
  <Tabs.List>
    <Tabs.Tab value="tab1">First</Tabs.Tab>
    <Tabs.Tab value="tab2">Second</Tabs.Tab>
  </Tabs.List>

  <Tabs.Panel value="tab1">First content</Tabs.Panel>
  <Tabs.Panel value="tab2">Second content</Tabs.Panel>
</Tabs>

Best Practices

Do’s ✅

// ✅ Explicitly type children when needed
interface Props {
  children: ReactNode;
}

// ✅ Use function declarations for components
function Component(props: Props) {
  return <div>{props.children}</div>;
}

// ✅ Extend HTML element props when appropriate
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
}

// ✅ Use discriminated unions for conditional props
type Props =
  | { type: 'text'; value: string }
  | { type: 'number'; value: number; min?: number; max?: number };

// ✅ Type event handlers explicitly when extracted
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
  // ...
};

Don’ts ❌

// ❌ Don't use React.FC unnecessarily
const Component: React.FC = () => { };

// ❌ Don't use any for event handlers
onClick={(e: any) => { }}

// ❌ Don't forget display names on forwardRef
const Input = forwardRef((props, ref) => { });
// Input.displayName = 'Input'; // Don't forget this!

// ❌ Don't overuse type assertions
const element = document.getElementById('id') as HTMLInputElement; // Dangerous!

// ❌ Don't ignore TypeScript errors in components
// @ts-ignore // Never do this in components!

Summary

These React-specific TypeScript patterns form the foundation of type-safe React development. Master these patterns and you’ll write components that are not only type-safe but also more maintainable and easier to refactor. Remember: the goal isn’t to add types everywhere, but to add the right types in the right places to catch errors early and improve your development experience.

Last modified on .