Back to Blog

Advanced TypeScript Patterns in React

Advanced TypeScript Patterns in React

February 28, 2024

9 min read
TypeScript
React
Development
Best Practices

TypeScript Design Patterns

TypeScript enhances JavaScript with a robust type system. Here are some essential patterns for writing type-safe React applications.

Props Type Definition

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

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

Generic Components

Use generics to make components reusable for different data types.

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

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

// Usage:
<List<string>
  items={["apple", "banana"]}
  renderItem={fruit => <span>{fruit.toUpperCase()}</span>}
/>

Utility Types

Leverage built-in utility types like Partial, Pick, and Omit for concise transformations.

interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
}

// Make all properties optional
type PartialUser = Partial<User>;
// Pick only id and name
type UserSummary = Pick<User, 'id' | 'name'>;
// Omit sensitive fields
type SafeUser = Omit<User, 'email' | 'isAdmin'>;

Discriminated Unions

Use unions with a common discriminant to model variants with exhaustive type checks.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    default:
      // Ensure all cases are handled
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Type Guards

Narrow down types at runtime with custom guard functions.

function isDate(value: any): value is Date {
  return value instanceof Date;
}

function process(input: string | Date) {
  if (isDate(input)) {
    console.log(input.toISOString());
  } else {
    console.log(input.trim());
  }
}

Polymorphic Components

Build components that can render as different HTML elements while preserving props.

import React from 'react';

interface TextProps<E extends React.ElementType> {
  as?: E;
  children: React.ReactNode;
}

type PolymorphicText = <E extends React.ElementType = 'span'>(
  props: TextProps<E> & Omit<React.ComponentProps<E>, keyof TextProps<E>>
) => React.ReactElement | null;

const Text: PolymorphicText = ({ as: Component = 'span', children, ...rest }) => {
  return <Component {...rest}>{children}</Component>;
};

// Usage:
<Text as="h1" style={{ fontWeight: 'bold' }}>Heading</Text>

Context API Pattern

Strongly type context values and providers for predictable usage.

interface AuthContextType {
  user: { id: number; name: string } | null;
  login: (username: string, password: string) => Promise<void>;
}

const AuthContext = React.createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = React.useState<{ id: number; name: string } | null>(null);

  const login = async (u: string, p: string) => {
    // ...auth logic
    setUser({ id: 1, name: u });
  };

  return <AuthContext.Provider value={{ user, login }}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = React.useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}

Render Props Pattern

Pass a render function to share common logic while customizing UI.

interface DataFetcherProps<T> {
  url: string;
  children: (data: T | null, loading: boolean) => React.ReactNode;
}

function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
  const [data, setData] = React.useState<T | null>(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(json => setData(json))
      .finally(() => setLoading(false));
  }, [url]);

  return <>{children(data, loading)}</>;
}

// Usage:
<DataFetcher<{ name: string }> url="/api/user">
  {(user, loading) =>
    loading ? <p>Loading...</p> : <p>User: {user?.name}</p>
  }
</DataFetcher>

Higher-Order Components

Wrap components to inject props or behavior while preserving types.

function withTimestamp<P>(Component: React.ComponentType<P>) {
  return (props: P) => <Component {...props} timestamp={Date.now()} />;
}

// Usage:
interface WidgetProps {
  title: string;
  timestamp?: number;
}

function Widget({ title, timestamp }: WidgetProps) {
  return (
    <div>
      <h3>{title}</h3>
      <small>{timestamp}</small>
    </div>
  );
}

export const TimedWidget = withTimestamp(Widget);