TypeScript Generics in React Components: A Complete Guide

typescript-generics-in-react-components:-a-complete-guide

I didn’t want to write about using Generics in React Components until last week, when I received two comments from the engineers on my team.

The syntax looks invalid
The usage of generics throws me off

At that moment, I realised that this was an opportunity to share what I knew about using TypeScript Generics in React Components that could help some engineers, and get the team aligned on why, how, and when to use generics.

1. Introduction: Why Generics in React Components?

When building React apps with TypeScript, you’ll often find yourself creating components that work with different types of data but follow the same structure. Without generics, you might end up with repetitive code or lose type safety.

Consider this common scenario: you have a list component that displays users in one place and products in another. Without generics, you might create separate components or use any types, both of which lead to problems.

The problem with non-generic components:

// Without generics - not reusable
interface UserListProps {
  items: User[];
  onSelect: (item: User) => void;
}

interface ProductListProps {
  items: Product[];
  onSelect: (item: Product) => void;
}

// Or worse - lose type safety
interface GenericListProps {
  items: any[];
  onSelect: (item: any) => void;
}

Type safety benefits of generics include catching errors at compile time, better IntelliSense support, and self-documenting code. Code reusability and maintainability improve because you write the logic once and apply it to multiple types.

2. TypeScript Generics Fundamentals

Before diving into React-specific patterns, let’s review the basics of TypeScript generics.

Generic syntax uses angle brackets to define type parameters:

function echo<T>(arg: T): T {
  return arg;
}

// Usage
const result = echo<string>("hello"); // T is string
const number = echo(42); // T is inferred as number

Generic constraints and bounds limit what types can be used:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length);
  return arg;
}

logLength("hello"); // Works - string has length
logLength([1, 2, 3]); // Works - array has length
// logLength(42); // Error - number doesn't have length

Default generic parameters provide fallback types:

interface ApiResponse<TData = unknown> {
  data: TData;
  status: number;
}

// Uses default unknown type
const response: ApiResponse = { data: {}, status: 200 };

// Explicit type
const userResponse: ApiResponse<User> = { data: user, status: 200 };

3. Your First Generic React Component

Let’s convert a simple component to use generics. Here’s a basic list component:

// Non-generic version
interface User {
  id: number;
  name: string;
  email: string;
}

interface UserListProps {
  users: User[];
  onUserSelect: (user: User) => void;
}

function UserList({ users, onUserSelect }: UserListProps) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={() => onUserSelect(user)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

Now let’s make it generic:

// Generic version
interface ListItem {
  id: number | string;
}

interface GenericListProps<T extends ListItem> {
  items: T[];
  onItemSelect: (item: T) => void;
  renderItem: (item: T) => React.ReactNode;
}

function GenericList<T extends ListItem>({ 
  items, 
  onItemSelect, 
  renderItem 
}: GenericListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onItemSelect(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}

Use generics:

// Usage with explicit type
<GenericList<User>
  items={users}
  onItemSelect={handleUserSelect}
  renderItem={(user) => user.name}
/>

// TypeScript can often infer the type
<GenericList
  items={products} // TypeScript infers Product[]
  onItemSelect={handleProductSelect}
  renderItem={(product) => product.title}
/>

Props interface with generic types ensures type safety throughout the component. The T extends ListItem constraint ensures all items have an id property for the key prop.

Type safety? Yes.

Imagine you pass the wrong handler to the list.

 product.title}
/>

TypeScript will pick it up before shipping this bug to your pipeline or your customers.

An example can be found here.

4. Common Patterns and Use Cases

Generic list components are perfect for displaying collections of any type:

interface TableColumn<T> {
  key: keyof T;
  title: string;
  render?: (value: T[keyof T], item: T) => React.ReactNode;
}

interface DataTableProps<T> {
  data: T[];
  columns: TableColumn<T>[];
  onRowClick?: (item: T) => void;
}

function DataTable<T>({ data, columns, onRowClick }: DataTableProps<T>) {
  return (
    <table>
      <thead>
        <tr>
          {columns.map(col => (
            <th key={String(col.key)}>{col.title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, index) => (
          <tr key={index} onClick={() => onRowClick?.(item)}>
            {columns.map(col => (
              <td key={String(col.key)}>
                {col.render 
                  ? col.render(item[col.key], item)
                  : String(item[col.key])
                }
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Form components with typed values:

interface FormFieldProps<T> {
  value: T;
  onChange: (value: T) => void;
  label: string;
  error?: string;
}

function FormField<T extends string | number>({ 
  value, 
  onChange, 
  label, 
  error 
}: FormFieldProps<T>) {
  return (
    <div>
      <label>{label}</label>
      <input
        type={typeof value === 'number' ? 'number' : 'text'}
        value={value}
        onChange={(e) => {
          const newValue = typeof value === 'number' 
            ? Number(e.target.value) as T
            : e.target.value as T;
          onChange(newValue);
        }}
      />
      {error && <span className="error">{error}</span>}
    </div>
  );
}

Modal components with typed data:

interface ModalProps<T> {
  isOpen: boolean;
  onClose: () => void;
  data: T;
  renderContent: (data: T) => React.ReactNode;
  title?: string;
}

function Modal<T>({ 
  isOpen, 
  onClose, 
  data, 
  renderContent, 
  title 
}: ModalProps<T>) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {title && <h2>{title}</h2>}
        <button onClick={onClose}>×</button>
        {renderContent(data)}
      </div>
    </div>
  );
}

// Usage
<Modal<User>
  isOpen={showUserModal}
  onClose={() => setShowUserModal(false)}
  data={selectedUser}
  title="User Details"
  renderContent={(user) => (
    <div>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  )}
/>

5. Prop Inference and Type Safety

TypeScript’s type inference makes generic components feel natural to use. Here’s how it works:

How TypeScript infers generic types from props:

interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string | number;
}

function Select<T>(props: SelectProps<T>) {
  // Component implementation
}

// TypeScript infers T as User from the options prop
<Select
  options={users} // User[]
  value={selectedUser} // User
  onChange={setSelectedUser} // (user: User) => void
  getLabel={(user) => user.name} // user is typed as User
  getValue={(user) => user.id} // user is typed as User
/>

Explicit vs implicit type parameters:

// Explicit - useful when inference isn't possible
<Select<User>
  options={[]}
  value={undefined}
  onChange={handleChange}
  getLabel={(user) => user.name}
  getValue={(user) => user.id}
/>

// Implicit - TypeScript figures it out
<Select
  options={users}
  value={selectedUser}
  onChange={handleChange}
  getLabel={(user) => user.name}
  getValue={(user) => user.id}
/>

Type narrowing in component props happens when you provide more specific constraints:

interface SearchableItem {
  searchableText: string;
}

interface SearchableListProps<T extends SearchableItem> {
  items: T[];
  onSearch: (query: string, items: T[]) => T[];
}

function SearchableList<T extends SearchableItem>(props: SearchableListProps<T>) {
  // T is guaranteed to have searchableText property
  const filteredItems = props.items.filter(item => 
    item.searchableText.toLowerCase().includes(searchQuery.toLowerCase())
  );
}

6. Advanced Generic Patterns

Multiple generic parameters allow for complex component relationships:

interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

interface KeyValueListProps<K, V> {
  pairs: KeyValuePair<K, V>[];
  onPairSelect: (key: K, value: V) => void;
  renderKey: (key: K) => React.ReactNode;
  renderValue: (value: V) => React.ReactNode;
}

function KeyValueList<K, V>({ 
  pairs, 
  onPairSelect, 
  renderKey, 
  renderValue 
}: KeyValueListProps<K, V>) {
  return (
    <div>
      {pairs.map((pair, index) => (
        <div 
          key={index} 
          onClick={() => onPairSelect(pair.key, pair.value)}
        >
          <span>{renderKey(pair.key)}</span>: 
          <span>{renderValue(pair.value)}</span>
        </div>
      ))}
    </div>
  );
}

Generic constraints with extends provide powerful type checking:

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface TimelineProps<T extends Timestamped> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  sortOrder: 'asc' | 'desc';
}

function Timeline<T extends Timestamped>({ 
  items, 
  renderItem, 
  sortOrder 
}: TimelineProps<T>) {
  const sortedItems = [...items].sort((a, b) => {
    const dateA = a.createdAt.getTime();
    const dateB = b.createdAt.getTime();
    return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
  });

  return (
    <div className="timeline">
      {sortedItems.map((item, index) => (
        <div key={index} className="timeline-item">
          <time>{item.createdAt.toLocaleDateString()}</time>
          {renderItem(item)}
        </div>
      ))}
    </div>
  );
}

Conditional types in component props:

type ConditionalProps<T> = T extends string 
  ? { stringProp: string }
  : T extends number 
  ? { numberProp: number }
  : { genericProp: T };

interface ConditionalComponentProps<T> extends ConditionalProps<T> {
  value: T;
}

function ConditionalComponent<T>(props: ConditionalComponentProps<T>) {
  // Component logic based on conditional props
}

Generic utility types (Pick, Omit, Partial):

interface FullUser {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

interface UserCardProps<T extends Pick<FullUser, 'id' | 'name' | 'email'>> {
  user: T;
  onEdit?: (user: T) => void;
  showEmail?: boolean;
}

function UserCard<T extends Pick<FullUser, 'id' | 'name' | 'email'>>({ 
  user, 
  onEdit, 
  showEmail = true 
}: UserCardProps<T>) {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      {showEmail && <p>{user.email}</p>}
      {onEdit && <button onClick={() => onEdit(user)}>Edit</button>}
    </div>
  );
}

7. Generic Hooks and Custom Logic

Creating generic custom hooks:

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue] as const;
}

// Usage in component
function UserPreferences() {
  const [preferences, setPreferences] = useLocalStorage<UserPrefs>('userPrefs', {
    theme: 'light',
    language: 'en'
  });
}

Combining generic components with generic hooks:

function useAsyncData<T>(fetchFn: () => Promise<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    fetchFn()
      .then(setData)
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  return { data, loading, error };
}

interface AsyncDataDisplayProps<T> {
  fetchFn: () => Promise<T>;
  renderData: (data: T) => React.ReactNode;
  renderLoading?: () => React.ReactNode;
  renderError?: (error: string) => React.ReactNode;
}

function AsyncDataDisplay<T>({ 
  fetchFn, 
  renderData, 
  renderLoading = () => <div>Loading...</div>,
  renderError = (error) => <div>Error: {error}</div>
}: AsyncDataDisplayProps<T>) {
  const { data, loading, error } = useAsyncData(fetchFn);

  if (loading) return renderLoading();
  if (error) return renderError(error);
  if (!data) return null;

  return <>{renderData(data)}</>;
}

8. Real-World Examples

Building a generic data fetcher component:

interface ApiConfig {
  baseURL: string;
  headers?: Record<string, string>;
}

interface DataFetcherProps<T> {
  url: string;
  config?: ApiConfig;
  transform?: (data: any) => T;
  renderData: (data: T) => React.ReactNode;
  renderLoading?: () => React.ReactNode;
  renderError?: (error: Error) => React.ReactNode;
}

function DataFetcher<T>({
  url,
  config = { baseURL: '' },
  transform = (data) => data,
  renderData,
  renderLoading = () => <div>Loading...</div>,
  renderError = (error) => <div>Error: {error.message}</div>
}: DataFetcherProps<T>) {
  const [state, setState] = useState<{
    data: T | null;
    loading: boolean;
    error: Error | null;
  }>({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    const fetchData = async () => {
      try {
        setState(prev => ({ ...prev, loading: true, error: null }));
        const response = await fetch(`${config.baseURL}${url}`, {
          headers: config.headers
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const rawData = await response.json();
        const transformedData = transform(rawData);
        setState({ data: transformedData, loading: false, error: null });
      } catch (error) {
        setState({ 
          data: null, 
          loading: false, 
          error: error instanceof Error ? error : new Error('Unknown error')
        });
      }
    };

    fetchData();
  }, [url, config.baseURL]);

  if (state.loading) return renderLoading();
  if (state.error) return renderError(state.error);
  if (!state.data) return null;

  return <>{renderData(state.data)}</>;
}

// Usage
interface User {
  id: number;
  name: string;
  email: string;
}

<DataFetcher<User[]>
  url="/api/users"
  config={{ baseURL: 'https://api.example.com' }}
  transform={(data) => data.users}
  renderData={(users) => (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} - {user.email}</li>
      ))}
    </ul>
  )}
/>

Creating a reusable form field component:

interface ValidationRule<T> {
  validate: (value: T) => boolean;
  message: string;
}

interface FormFieldProps<T> {
  label: string;
  value: T;
  onChange: (value: T) => void;
  type?: 'text' | 'email' | 'number' | 'password';
  placeholder?: string;
  required?: boolean;
  validationRules?: ValidationRule<T>[];
  renderInput?: (props: {
    value: T;
    onChange: (value: T) => void;
    hasError: boolean;
  }) => React.ReactNode;
}

function FormField<T extends string | number>({
  label,
  value,
  onChange,
  type = 'text',
  placeholder,
  required = false,
  validationRules = [],
  renderInput
}: FormFieldProps<T>) {
  const [touched, setTouched] = useState(false);

  const errors = validationRules
    .filter(rule => !rule.validate(value))
    .map(rule => rule.message);

  const hasError = touched && errors.length > 0;

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = type === 'number' 
      ? Number(e.target.value) as T
      : e.target.value as T;
    onChange(newValue);
  };

  const defaultInput = (
    <input
      type={type}
      value={value}
      onChange={handleChange}
      onBlur={() => setTouched(true)}
      placeholder={placeholder}
      className={hasError ? 'error' : ''}
      required={required}
    />
  );

  return (
    <div className="form-field">
      <label>
        {label}
        {required && <span className="required">*</span>}
      </label>
      {renderInput 
        ? renderInput({ value, onChange, hasError })
        : defaultInput
      }
      {hasError && (
        <div className="error-messages">
          {errors.map((error, index) => (
            <span key={index} className="error-message">
              {error}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// Usage
const emailValidation: ValidationRule<string> = {
  validate: (value) => /^[^s@]+@[^s@]+.[^s@]+$/.test(value),
  message: 'Please enter a valid email address'
};

<FormField<string>
  label="Email"
  value={email}
  onChange={setEmail}
  type="email"
  required
  validationRules={[emailValidation]}
/>

9. Best Practices and Common Pitfalls

When to use generics vs unions:

Use generics when you need the same structure with different types:

// Good use of generics
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

Use unions when you have a limited set of known types:

// Good use of unions
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'small' | 'medium' | 'large';
}

Naming conventions for generic parameters:

  • Use T for a single generic type
  • Use descriptive names for multiple parameters: TData, TError, TKey, TValue
  • Use T prefix for generic type parameters to distinguish from regular types
// Good naming
interface DataTableProps<TData, TColumn> {
  data: TData[];
  columns: TColumn[];
}

// Clear and descriptive
interface ApiHookResult<TData, TError = Error> {
  data: TData | null;
  error: TError | null;
  loading: boolean;
}

Performance considerations:

  • Generic components don’t have runtime overhead
  • TypeScript generics are compile-time only
  • Be mindful of complex conditional types that can slow down compilation

Debugging generic type errors:

// Use type assertions for debugging
const debugType = <T>(value: T): T => {
  console.log('Type:', typeof value, 'Value:', value);
  return value;
};

// Use utility types to inspect complex types
type InspectProps<T> = {
  [K in keyof T]: T[K]
};

Generics with React Query/SWR:

import { useQuery } from '@tanstack/react-query';

interface QueryComponentProps<TData, TError = Error> {
  queryKey: string[];
  queryFn: () => Promise<TData>;
  renderData: (data: TData) => React.ReactNode;
  renderError?: (error: TError) => React.ReactNode;
}

function QueryComponent<TData, TError = Error>({
  queryKey,
  queryFn,
  renderData,
  renderError = (error) => <div>Error: {String(error)}</div>
}: QueryComponentProps<TData, TError>) {
  const { data, error, isLoading } = useQuery<TData, TError>({
    queryKey,
    queryFn
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return renderError(error);
  if (!data) return null;

  return <>{renderData(data)}</>;
}

Form libraries (React Hook Form):

import { useForm, FieldValues, Path } from 'react-hook-form';

interface FormInputProps<T extends FieldValues> {
  name: Path<T>;
  label: string;
  register: ReturnType<typeof useForm<T>>['register'];
  errors: ReturnType<typeof useForm<T>>['formState']['errors'];
  type?: string;
}

function FormInput<T extends FieldValues>({
  name,
  label,
  register,
  errors,
  type = 'text'
}: FormInputProps<T>) {
  const error = errors[name];

  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        type={type}
        {...register(name)}
        className={error ? 'error' : ''}
      />
      {error && <span className="error">{error.message}</span>}
    </div>
  );
}

11. Testing Generic Components

Writing tests for generic components:

import { render, screen, fireEvent } from '@testing-library/react';
import { GenericList } from './GenericList';

interface TestItem {
  id: number;
  name: string;
}

describe('GenericList', () => {
  const mockItems: TestItem[] = [
    { id: 1, name: 'Item 1' },
    { id: 2, name: 'Item 2' }
  ];

  it('renders items correctly', () => {
    const mockOnSelect = jest.fn();

    render(
      <GenericList<TestItem>
        items={mockItems}
        onItemSelect={mockOnSelect}
        renderItem={(item) => item.name}
      />
    );

    expect(screen.getByText('Item 1')).toBeInTheDocument();
    expect(screen.getByText('Item 2')).toBeInTheDocument();
  });

  it('calls onItemSelect with correct type', () => {
    const mockOnSelect = jest.fn();

    render(
      <GenericList<TestItem>
        items={mockItems}
        onItemSelect={mockOnSelect}
        renderItem={(item) => item.name}
      />
    );

    fireEvent.click(screen.getByText('Item 1'));

    expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]);
    // TypeScript ensures the parameter is TestItem, not any
  });
});

Mocking generic props:

// Create typed mocks for better test safety
const createMockProps = <T>(): GenericListProps<T> => ({
  items: [] as T[],
  onItemSelect: jest.fn(),
  renderItem: jest.fn().mockReturnValue('mocked content')
});

// Use in tests
const mockProps = createMockProps<TestItem>();

Type assertion strategies:

// When you need to assert types in tests
const assertType = <T>(value: unknown): asserts value is T => {
  // Runtime type checking logic if needed
};

// Or use TypeScript's assertion functions
function isTestItem(item: unknown): item is TestItem {
  return typeof item === 'object' && 
         item !== null && 
         'id' in item && 
         'name' in item;
}

Conclusion

TypeScript generics in React components provide a powerful way to create reusable, type-safe components that work with different data types while maintaining excellent developer experience. By following the patterns and best practices outlined in this guide, your team can build more maintainable and robust React applications.

The key is to start simple with basic generic components and gradually adopt more advanced patterns as your needs grow. Remember that generics should make your code more reusable and type-safe, not more complex. When in doubt, prefer explicit types over overly complex generic constraints. (Yes, I’ve seen too many over-engineering examples, including all of mine from the past decades)

Start implementing these patterns in your codebase today, and you’ll find that your components become more flexible, your code becomes more maintainable, and your development experience improves significantly.

References and Further Reading

Official Documentation

In-Depth Articles and Tutorials

Practical Guides and Tips

Community Resources

These references provide a mix of official documentation, practical tutorials, and community resources that complement the topics covered in this guide. They range from beginner-friendly explanations to advanced patterns, giving your team multiple perspectives and learning paths for mastering TypeScript generics in React.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
sunday-rewind:-essential-kpis-for-data-driven-product-managers

Sunday Rewind: Essential KPIs for data-driven product managers

Next Post
intelligent-automation:-a-recipe-for-enhancing-quality,-productivity,-and-efficiency

Intelligent Automation: A Recipe for Enhancing Quality, Productivity, and Efficiency

Related Posts