Generics в React с TypeScript

19 июня 2026
Автор

Олег Марков

Введение

Дженерики (generics) — одна из самых мощных возможностей TypeScript, и они особенно полезны в React-разработке. С их помощью вы создаёте компоненты и хуки, которые работают с любыми типами данных, сохраняя при этом полную типобезопасность. В этой статье мы разберём дженерики с практическими примерами из реального проекта.

Зачем нужны дженерики в React

Рассмотрим проблему без дженериков. Допустим, нужен компонент списка:

// Проблема: жёсткая привязка к типу
function UserList({ users }: { users: User[] }) {
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// Приходится дублировать для каждого типа
function ProductList({ products }: { products: Product[] }) {
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

С дженериками — один компонент для всего:

// Решение: generic компонент
function List<T extends { id: number | string }>({
  items,
  renderItem,
}: {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}) {
  return <ul>{items.map(item => <li key={String(item.id)}>{renderItem(item)}</li>)}</ul>;
}

// Работает с любым типом
<List items={users} renderItem={user => user.name} />
<List items={products} renderItem={product => `${product.name} — ${product.price} ₽`} />

Базовый синтаксис generic компонентов

// Синтаксис: <T> перед параметрами компонента
function Container<T>({ value, render }: {
  value: T;
  render: (value: T) => React.ReactNode;
}) {
  return <div>{render(value)}</div>;
}

// С ограничением типа (constraint)
function Container<T extends object>({ value, render }: {
  value: T;
  render: (value: T) => React.ReactNode;
}) {
  return <div>{render(value)}</div>;
}

// Множественные дженерики
function Transformer<TInput, TOutput>({
  value,
  transform,
  render,
}: {
  value: TInput;
  transform: (input: TInput) => TOutput;
  render: (output: TOutput) => React.ReactNode;
}) {
  const transformed = transform(value);
  return <div>{render(transformed)}</div>;
}

Generic компонент Select

Один из самых частых кандидатов для дженерика — компонент выпадающего списка:

interface SelectOption<T> {
  value: T;
  label: string;
  disabled?: boolean;
}

interface SelectProps<T> {
  options: SelectOption<T>[];
  value: T | null;
  onChange: (value: T) => void;
  placeholder?: string;
  disabled?: boolean;
  className?: string;
  // Кастомный рендер опций
  renderOption?: (option: SelectOption<T>) => React.ReactNode;
  // Как сравнивать значения
  getKey?: (value: T) => string | number;
}

function Select<T>({
  options,
  value,
  onChange,
  placeholder = 'Выберите...',
  disabled = false,
  className = '',
  renderOption,
  getKey = (v) => String(v),
}: SelectProps<T>) {
  const [isOpen, setIsOpen] = React.useState(false);
  
  const selectedOption = value !== null
    ? options.find(opt => getKey(opt.value) === getKey(value))
    : null;
  
  return (
    <div className={`select ${isOpen ? 'select--open' : ''} ${className}`}>
      <button
        type="button"
        disabled={disabled}
        onClick={() => setIsOpen(!isOpen)}
        className="select__trigger"
      >
        {selectedOption ? selectedOption.label : placeholder}
      </button>
      
      {isOpen && (
        <ul className="select__dropdown">
          {options.map(option => (
            <li
              key={String(getKey(option.value))}
              className={`select__option ${option.disabled ? 'select__option--disabled' : ''}`}
              onClick={() => {
                if (!option.disabled) {
                  onChange(option.value);
                  setIsOpen(false);
                }
              }}
            >
              {renderOption ? renderOption(option) : option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Использование с разными типами
interface Country {
  code: string;
  name: string;
  flag: string;
}

const CountrySelector = () => {
  const [selectedCountry, setSelectedCountry] = React.useState<Country | null>(null);
  
  const countryOptions: SelectOption<Country>[] = [
    { value: { code: 'RU', name: 'Россия', flag: '🇷🇺' }, label: 'Россия' },
    { value: { code: 'US', name: 'США', flag: '🇺🇸' }, label: 'США' },
    { value: { code: 'DE', name: 'Германия', flag: '🇩🇪' }, label: 'Германия' },
  ];
  
  return (
    <Select<Country>
      options={countryOptions}
      value={selectedCountry}
      onChange={setSelectedCountry}
      getKey={country => country.code}
      renderOption={option => (
        <span>{option.value.flag} {option.label}</span>
      )}
    />
  );
};

Generic компонент Table

Таблица данных — идеальный кейс для дженериков:

interface Column<T> {
  key: string;
  header: string;
  // Функция для получения значения из строки
  accessor: (row: T) => React.ReactNode;
  // Ширина колонки
  width?: string;
  // Можно ли сортировать по этой колонке
  sortable?: boolean;
  // Кастомный рендер заголовка
  renderHeader?: () => React.ReactNode;
}

interface TableProps<T extends { id: number | string }> {
  data: T[];
  columns: Column<T>[];
  // Сортировка
  sortColumn?: string;
  sortDirection?: 'asc' | 'desc';
  onSort?: (column: string) => void;
  // Выбор строк
  selectedIds?: Set<T['id']>;
  onSelectRow?: (id: T['id']) => void;
  onSelectAll?: (selected: boolean) => void;
  // Состояния
  isLoading?: boolean;
  emptyText?: string;
  // Клик по строке
  onRowClick?: (row: T) => void;
}

function Table<T extends { id: number | string }>({
  data,
  columns,
  sortColumn,
  sortDirection,
  onSort,
  selectedIds,
  onSelectRow,
  onSelectAll,
  isLoading = false,
  emptyText = 'Нет данных',
  onRowClick,
}: TableProps<T>) {
  const allSelected = selectedIds && data.length > 0 && 
    data.every(row => selectedIds.has(row.id));
  
  if (isLoading) {
    return <div className="table-loading">Загрузка...</div>;
  }
  
  return (
    <table className="table">
      <thead>
        <tr>
          {onSelectRow && (
            <th>
              <input
                type="checkbox"
                checked={allSelected}
                onChange={e => onSelectAll?.(e.target.checked)}
              />
            </th>
          )}
          {columns.map(column => (
            <th
              key={column.key}
              style={{ width: column.width }}
              className={column.sortable ? 'table__th--sortable' : ''}
              onClick={() => column.sortable && onSort?.(column.key)}
            >
              {column.renderHeader ? column.renderHeader() : column.header}
              {sortColumn === column.key && (
                <span className="sort-icon">{sortDirection === 'asc' ? '↑' : '↓'}</span>
              )}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.length === 0 ? (
          <tr>
            <td colSpan={columns.length + (onSelectRow ? 1 : 0)}>{emptyText}</td>
          </tr>
        ) : (
          data.map(row => (
            <tr
              key={String(row.id)}
              onClick={() => onRowClick?.(row)}
              className={`${onRowClick ? 'table__row--clickable' : ''} ${
                selectedIds?.has(row.id) ? 'table__row--selected' : ''
              }`}
            >
              {onSelectRow && (
                <td onClick={e => e.stopPropagation()}>
                  <input
                    type="checkbox"
                    checked={selectedIds?.has(row.id) ?? false}
                    onChange={() => onSelectRow(row.id)}
                  />
                </td>
              )}
              {columns.map(column => (
                <td key={column.key}>{column.accessor(row)}</td>
              ))}
            </tr>
          ))
        )}
      </tbody>
    </table>
  );
}

// Использование
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

const UserTable = () => {
  const [users, setUsers] = React.useState<User[]>([]);
  const [selectedIds, setSelectedIds] = React.useState<Set<number>>(new Set());
  
  const columns: Column<User>[] = [
    {
      key: 'name',
      header: 'Имя',
      accessor: user => user.name,
      sortable: true,
    },
    {
      key: 'email',
      header: 'Email',
      accessor: user => <a href={`mailto:${user.email}`}>{user.email}</a>,
    },
    {
      key: 'role',
      header: 'Роль',
      accessor: user => (
        <span className={`badge badge--${user.role}`}>
          {user.role === 'admin' ? 'Администратор' : 'Пользователь'}
        </span>
      ),
    },
    {
      key: 'createdAt',
      header: 'Дата регистрации',
      accessor: user => user.createdAt.toLocaleDateString('ru-RU'),
      sortable: true,
    },
  ];
  
  const toggleSelect = (id: number) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };
  
  return (
    <Table<User>
      data={users}
      columns={columns}
      selectedIds={selectedIds}
      onSelectRow={toggleSelect}
      onSelectAll={selected => {
        setSelectedIds(selected ? new Set(users.map(u => u.id)) : new Set());
      }}
    />
  );
};

Generic хуки

Дженерики особенно полезны для хуков, которые работают с разными типами данных:

// Generic хук для работы с API
interface UseAsyncState<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
}

type UseAsyncReturn<T> = UseAsyncState<T> & {
  execute: (...args: Parameters<() => Promise<T>>) => Promise<void>;
  reset: () => void;
};

function useAsync<T>(asyncFn: () => Promise<T>): UseAsyncReturn<T> {
  const [state, setState] = React.useState<UseAsyncState<T>>({
    data: null,
    isLoading: false,
    error: null,
  });
  
  const execute = React.useCallback(async () => {
    setState({ data: null, isLoading: true, error: null });
    try {
      const data = await asyncFn();
      setState({ data, isLoading: false, error: null });
    } catch (e) {
      setState({ data: null, isLoading: false, error: e instanceof Error ? e : new Error('Unknown error') });
    }
  }, [asyncFn]);
  
  const reset = React.useCallback(() => {
    setState({ data: null, isLoading: false, error: null });
  }, []);
  
  return { ...state, execute, reset };
}

// Хук для работы с коллекциями
function useCollection<T extends { id: number | string }>(initialItems: T[] = []) {
  const [items, setItems] = React.useState<T[]>(initialItems);
  
  const add = React.useCallback((item: T) => {
    setItems(prev => [...prev, item]);
  }, []);
  
  const update = React.useCallback((id: T['id'], updates: Partial<T>) => {
    setItems(prev =>
      prev.map(item => item.id === id ? { ...item, ...updates } : item)
    );
  }, []);
  
  const remove = React.useCallback((id: T['id']) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []);
  
  const find = React.useCallback((id: T['id']): T | undefined => {
    return items.find(item => item.id === id);
  }, [items]);
  
  const findAll = React.useCallback((predicate: (item: T) => boolean): T[] => {
    return items.filter(predicate);
  }, [items]);
  
  return { items, add, update, remove, find, findAll };
}

// Использование
interface Todo {
  id: number;
  text: string;
  completed: boolean;
  priority: 'low' | 'medium' | 'high';
}

function TodoApp() {
  const todos = useCollection<Todo>([]);
  
  const addTodo = (text: string) => {
    todos.add({
      id: Date.now(),
      text,
      completed: false,
      priority: 'medium',
    });
  };
  
  const highPriorityTodos = todos.findAll(todo => todo.priority === 'high');
  
  return (
    <div>
      <p>Высокий приоритет: {highPriorityTodos.length}</p>
      {todos.items.map(todo => (
        <div key={todo.id}>
          <span>{todo.text}</span>
          <button onClick={() => todos.update(todo.id, { completed: !todo.completed })}>
            {todo.completed ? 'Отменить' : 'Выполнено'}
          </button>
          <button onClick={() => todos.remove(todo.id)}>Удалить</button>
        </div>
      ))}
    </div>
  );
}

Render Props с дженериками

Паттерн render props становится очень мощным с дженериками:

interface DataFetcherProps<T> {
  url: string;
  // Трансформация данных перед рендером
  transform?: (data: unknown) => T;
  children: (state: {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
    refetch: () => void;
  }) => React.ReactNode;
}

function DataFetcher<T>({ url, transform, children }: DataFetcherProps<T>) {
  const [data, setData] = React.useState<T | null>(null);
  const [isLoading, setIsLoading] = React.useState(true);
  const [error, setError] = React.useState<Error | null>(null);
  
  const fetchData = React.useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const raw = await response.json();
      setData(transform ? transform(raw) : raw as T);
    } catch (e) {
      setError(e instanceof Error ? e : new Error('Unknown error'));
    } finally {
      setIsLoading(false);
    }
  }, [url, transform]);
  
  React.useEffect(() => { fetchData(); }, [fetchData]);
  
  return <>{children({ data, isLoading, error, refetch: fetchData })}</>;
}

// Использование
<DataFetcher<User[]> url="/api/users">
  {({ data: users, isLoading, error, refetch }) => {
    if (isLoading) return <div>Загрузка...</div>;
    if (error) return <button onClick={refetch}>Повторить</button>;
    return <UserList users={users ?? []} />;
  }}
</DataFetcher>

Условные типы и дженерики

// Компонент, который меняет поведение в зависимости от типа
type ListOrSingle<T, Multiple extends boolean> = Multiple extends true
  ? { value: T[]; onChange: (value: T[]) => void; multiple: true }
  : { value: T | null; onChange: (value: T | null) => void; multiple?: false };

type CheckboxGroupProps<T> = {
  options: Array<{ value: T; label: string }>;
  getKey: (value: T) => string;
} & ListOrSingle<T, boolean>;

// Более простой вариант с двумя компонентами
function SingleSelect<T>({ options, value, onChange, getKey }: {
  options: Array<{ value: T; label: string }>;
  value: T | null;
  onChange: (value: T | null) => void;
  getKey: (value: T) => string;
}) {
  return (
    <div>
      {options.map(option => (
        <label key={getKey(option.value)}>
          <input
            type="radio"
            checked={value !== null && getKey(value) === getKey(option.value)}
            onChange={() => onChange(option.value)}
          />
          {option.label}
        </label>
      ))}
    </div>
  );
}

Лучшие практики

1. Добавляйте ограничения типов (constraints) когда это нужно:

// Ограничение: T должен иметь поле id
function List<T extends { id: number | string }>(props: { items: T[] }) {
  return props.items.map(item => <div key={String(item.id)} />);
}

2. Давайте дженерик-параметрам понятные имена:

// Плохо
function transform<T, U>(input: T): U { ... }

// Хорошо
function transform<TInput, TOutput>(input: TInput): TOutput { ... }

3. Указывайте тип явно только когда TypeScript не может его вывести:

// TypeScript сам выведет тип из аргументов
const result = transform(userData); // TInput = UserData

// Явно — только если нужно
const result = transform<UserData, UserDTO>(userData);

4. Используйте extends для безопасного доступа к свойствам:

// Доступ к .length безопасен благодаря ограничению
function first<T extends { length: number }>(collection: T): T extends any[] ? T[0] : never {
  return (collection as any)[0];
}

Заключение

Дженерики в React открывают возможность создания по-настоящему переиспользуемых компонентов и хуков. Ключевые моменты:

  • Используйте дженерики там, где компонент должен работать с разными типами данных
  • Добавляйте ограничения типов через extends для безопасного доступа к свойствам
  • Generic хуки особенно полезны для абстракций работы с API и коллекциями
  • Паттерн render props с дженериками даёт максимальную гибкость
  • Не злоупотребляйте дженериками — иногда проще написать два специфических компонента
Стрелочка влевоJest для React с TypeScript

Постройте личный план изучения Typescript до уровня Middle — бесплатно!

Typescript — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по Typescript

Открыть базу знаний

Лучшие курсы по теме

изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

CSS Flexbox

Антон Ларичев
Гарантия
Бонусы
иконка звёздочки рейтинга4.9
бесплатно
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий