Пошук уроків, статей та іншого контенту
Застосуєте узагальнені типи для універсальних компонентів списків, таблиць і компонентів-обгорток.
У React-компоненті часто повторюється одна й та сама структура, але дані мають різні типи:
список товарів;
список користувачів;
таблиця замовлень;
обгортка для станів завантаження;
компонент, який передає дані дочірньому вмісту.
Без узагальнених типів доводиться:
дублювати компоненти для кожного типу даних;
використовувати any;
втрачати перевірку типів у renderItem, children або columns.
Generics дають змогу описати структуру компонента один раз і передати конкретний тип під час використання.
function List<T>(props: ListProps<T>) {
// T — конкретний тип елемента списку
}Якщо компонент отримує User[], TypeScript підставить T = User. Якщо отримує Product[], T = Product.
Почнемо з компонента, який приймає масив елементів і функцію їх відображення.
import type { ReactNode } from "react";
type ListProps<T> = {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
getKey: (item: T) => string | number;
};
function List<T>({ items, renderItem, getKey }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={getKey(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}Компонент не знає, що саме зберігається в items. Він лише гарантує, що:
renderItem отримає елемент типу T;
getKey отримає елемент типу T;
обидві функції працюватимуть з одним і тим самим типом.
type User = {
id: number;
name: string;
email: string;
};
const users: User[] = [
{
id: 1,
name: "Олена",
email: "olena@example.com",
},
{
id: 2,
name: "Андрій",
email: "andrii@example.com",
},
];
function Users() {
return (
<List
items={users}
getKey={(user) => user.id}
renderItem={(user) => (
<span>
{user.name} — {user.email}
</span>
)}
/>
);
}TypeScript виводить тип T з властивості items. У цьому прикладі T стає User, тому редактор знає про user.name, user.email і user.id.
Якщо звернутися до неіснуючої властивості:
<List
items={users}
getKey={(user) => user.id}
renderItem={(user) => user.phone}
/>TypeScript повідомить про помилку, оскільки phone відсутня в типі User.
У більшості випадків тип виводиться автоматично. Але його можна вказати явно:
<List<User>
items={users}
getKey={(user) => user.id}
renderItem={(user) => <span>{user.name}</span>}
/>Явний тип корисний, коли:
тип не можна надійно вивести;
потрібно перевірити, що кілька властивостей узгоджені;
компонент використовується з порожнім масивом.
Наприклад, для порожнього масиву TypeScript не завжди може зрозуміти бажаний тип:
<List<User>
items={[]}
getKey={(user) => user.id}
renderItem={(user) => <span>{user.name}</span>}
/>У файлах .tsx запис із generic-параметром може бути сприйнятий як JSX:
// Потенційно неоднозначний синтаксис у .tsx
const List = <T>(props: ListProps<T>) => {
return null;
};У TSX після <T> потрібно додати кому:
const List = <T,>({ items, renderItem, getKey }: ListProps<T>) => {
return (
<ul>
{items.map((item, index) => (
<li key={getKey(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
};Кома <T,> повідомляє TypeScript, що це параметр типу, а не JSX-елемент.
Для generic-компонентів оголошення через function часто читається простіше:
function List<T>(props: ListProps<T>) {
// Реалізація компонента
}Іноді компоненту потрібна певна властивість у кожного елемента. Наприклад, таблиця повинна мати стабільний ідентифікатор.
Для цього використовується extends:
type Identifiable = {
id: string | number;
};
type TableProps<T extends Identifiable> = {
rows: T[];
};
function Table<T extends Identifiable>({ rows }: TableProps<T>) {
return (
<ul>
{rows.map((row) => (
<li key={row.id}>{row.id}</li>
))}
</ul>
);
}Тепер TypeScript гарантує, що T має властивість id.
Такий компонент можна використовувати з типом, який містить id:
type Product = {
id: number;
title: string;
price: number;
};
const products: Product[] = [];
<Table rows={products} />;А тип без id буде відхилено:
type Category = {
title: string;
};
// Помилка: Category не відповідає обмеженню Identifiable
<Table rows={[]} />;Обмеження не означає, що тип має складатися лише з id. Воно означає, що тип повинен містити щонайменше цю властивість.
Таблиця зазвичай отримує:
рядки;
опис колонок;
ключ кожної колонки;
необов’язкову функцію форматування значення.
import type { ReactNode } from "react";
type TableColumn<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => ReactNode;
};
type TableProps<T extends { id: string | number }> = {
rows: T[];
columns: TableColumn<T>[];
};
function Table<T extends { id: string | number }>({
rows,
columns,
}: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((column) => (
<th key={String(column.key)} scope="col">
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
{columns.map((column) => {
const value = row[column.key];
return (
<td key={String(column.key)}>
{column.render
? column.render(value, row)
: String(value)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}keyof T означає «одна з ключових властивостей типу T».
Для типу:
type Product = {
id: number;
title: string;
price: number;
};вираз keyof Product стає таким типом:
"id" | "title" | "price"Тому значення key не може бути довільним рядком:
const productColumns: TableColumn<Product>[] = [
{
key: "title",
header: "Назва",
},
{
key: "price",
header: "Ціна",
render: (value) => `${String(value)} грн`,
},
];Помилковий ключ буде відхилено:
const columns: TableColumn<Product>[] = [
{
// Помилка: "weight" відсутня в keyof Product
key: "weight",
header: "Вага",
},
];У попередньому прикладі render отримує T[keyof T]. Це об’єднання типів усіх властивостей.
Для Product це приблизно:
number | stringТому TypeScript не може гарантувати, що для key: "price" функція отримує саме number, а для key: "title" — саме string.
Якщо потрібно зберегти точний зв’язок між ключем і значенням, можна описати колонку за допомогою двох generic-параметрів:
type PreciseColumn<T, K extends keyof T> = {
key: K;
header: string;
render?: (value: T[K], row: T) => ReactNode;
};Для набору колонок використовується mapped type:
type PreciseColumns<T> = {
[K in keyof T]-?: PreciseColumn<T, K>;
}[keyof T][];Цей тип створює масив, у якому кожна колонка відповідає конкретному ключу T.
Приклад:
type Product = {
id: number;
title: string;
price: number;
};
const columns: PreciseColumns<Product> = [
{
key: "title",
header: "Назва",
render: (value) => value.toUpperCase(),
},
{
key: "price",
header: "Ціна",
render: (value) => `${value.toFixed(2)} грн`,
},
];Для колонки title параметр value має тип string, а для колонки price — number.
Це складніший варіант типізації. Його варто використовувати, коли форматування залежить від точного типу конкретного поля. Якщо достатньо універсального відображення через String(value), простішого T[keyof T] буде достатньо.
Компонент-обгортка може отримати дані типу T і передати їх у children як функцію.
Такий підхід називають function-as-children або render prop:
import type { ReactNode } from "react";
type DataStateProps<T> = {
data: T | null;
isLoading: boolean;
error?: string;
children: (data: T) => ReactNode;
};
function DataState<T>({
data,
isLoading,
error,
children,
}: DataStateProps<T>) {
if (isLoading) {
return <p>Завантаження...</p>;
}
if (error) {
return <p role="alert">{error}</p>;
}
if (data === null) {
return <p>Дані відсутні.</p>;
}
return <>{children(data)}</>;
}Тип T використовується одночасно в:
data: T | null;
children: (data: T) => ReactNode.
Приклад використання:
type Profile = {
id: number;
name: string;
role: string;
};
const profile: Profile = {
id: 1,
name: "Марія",
role: "Адміністратор",
};
function ProfilePage() {
return (
<DataState
data={profile}
isLoading={false}
error={undefined}
>
{(currentProfile) => (
<section>
<h1>{currentProfile.name}</h1>
<p>{currentProfile.role}</p>
</section>
)}
</DataState>
);
}Усередині children параметр currentProfile має тип Profile.
Це безпечніше, ніж передавати дані через unknown або any, оскільки дочірній код отримує повну інформацію про структуру даних.
Нижче наведено завершений приклад із generic-списком, таблицею та компонентом-обгорткою.
import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
type User = {
id: number;
name: string;
email: string;
active: boolean;
};
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T, index: number) => ReactNode;
};
function List<T>({
items,
getKey,
renderItem,
}: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={getKey(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
type TableColumn<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => ReactNode;
};
type TableProps<T extends { id: string | number }> = {
rows: T[];
columns: TableColumn<T>[];
};
function Table<T extends { id: string | number }>({
rows,
columns,
}: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((column) => (
<th key={String(column.key)} scope="col">
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
{columns.map((column) => {
const value = row[column.key];
return (
<td key={String(column.key)}>
{column.render
? column.render(value, row)
: String(value)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
type DataStateProps<T> = {
data: T | null;
isLoading: boolean;
error?: string;
children: (data: T) => ReactNode;
};
function DataState<T>({
data,
isLoading,
error,
children,
}: DataStateProps<T>) {
if (isLoading) {
return <p>Завантаження...</p>;
}
if (error) {
return <p role="alert">{error}</p>;
}
if (data === null) {
return <p>Дані відсутні.</p>;
}
return <>{children(data)}</>;
}
const users: User[] = [
{
id: 1,
name: "Олена",
email: "olena@example.com",
active: true,
},
{
id: 2,
name: "Андрій",
email: "andrii@example.com",
active: false,
},
];
const userColumns: TableColumn<User>[] = [
{
key: "name",
header: "Ім’я",
},
{
key: "email",
header: "Email",
},
{
key: "active",
header: "Статус",
render: (value) => (value ? "Активний" : "Неактивний"),
},
];
function App() {
const currentUser: User | null = users[0] ?? null;
return (
<main>
<h1>Користувачі</h1>
<h2>Список</h2>
<List
items={users}
getKey={(user) => user.id}
renderItem={(user) => (
<strong>
{user.name} — {user.email}
</strong>
)}
/>
<h2>Таблиця</h2>
<Table
rows={users}
columns={userColumns}
/>
<h2>Поточний користувач</h2>
<DataState
data={currentUser}
isLoading={false}
>
{(user) => <p>Вітаємо, {user.name}!</p>}
</DataState>
</main>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);У цьому прикладі:
List<T> працює з будь-яким типом елементів;
Table<T> приймає лише рядки, які мають id;
TableColumn<T> дозволяє використовувати тільки ключі рядка;
DataState<T> передає дочірньому вмісту дані правильного типу;
типи компонентів виводяться з переданих властивостей.
childrenGeneric-тип можна застосовувати не лише до масивів, а й до будь-якої властивості:
type FormFieldProps<T> = {
value: T;
onChange: (value: T) => void;
children: (value: T) => ReactNode;
};
function FormField<T>({
value,
onChange,
children,
}: FormFieldProps<T>) {
return (
<div>
{children(value)}
<button
type="button"
onClick={() => onChange(value)}
>
Застосувати
</button>
</div>
);
}Використання:
<FormField
value={42}
onChange={(value) => {
// value має тип number
console.log(value.toFixed(2));
}}
>
{(value) => <span>Поточне значення: {value}</span>}
</FormField>Використання з рядком автоматично змінює T:
<FormField
value="готово"
onChange={(value) => {
// value має тип string
console.log(value.toUpperCase());
}}
>
{(value) => <span>{value}</span>}
</FormField>Один компонент може залишатися універсальним, але при цьому зберігати точні типи у всіх callback-функціях.
React.FCДля звичайних компонентів можна використовувати React.FC, але generic-компоненти з ним описуються менш зручно:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => ReactNode;
};
// Компонент із generic-параметром зручніше оголосити через function
function List<T>(props: ListProps<T>) {
return null;
}React.FC не має способу автоматично оголосити T окремо для кожного використання компонента. Тому для універсальних компонентів зазвичай краще:
оголошувати компонент як generic-функцію;
описувати пропси через окремий generic-тип;
дозволяти TypeScript виводити T із властивостей.
anytype BadListProps = {
items: any[];
renderItem: (item: any) => ReactNode;
};any вимикає перевірку типів. Компонент більше не гарантує, що items і renderItem працюють з однаковими даними.
Краще:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => ReactNode;
};Якщо компонент використовує row.id, але тип не має такого обмеження:
function Table<T>({ rows }: { rows: T[] }) {
return (
<div>
{rows.map((row) => row.id)}
</div>
);
}TypeScript не дозволить звернутися до id, оскільки довільний T може її не мати.
Потрібно додати constraint:
function Table<T extends { id: string | number }>(
{ rows }: { rows: T[] },
) {
return (
<div>
{rows.map((row) => row.id)}
</div>
);
}ReactNodeReactNode підходить для результату рендерингу, але не для даних:
type BadProps = {
data: ReactNode;
};Такий тип не описує структуру даних і не дозволяє безпечно звертатися до її властивостей.
Потрібно розділяти тип даних і тип результату рендерингу:
type Props<T> = {
data: T;
children: (data: T) => ReactNode;
};Для стрілкової generic-функції в .tsx використовуйте кому:
const Component = <T,>(props: Props<T>) => {
return null;
};Або використовуйте оголошення через function:
function Component<T>(props: Props<T>) {
return null;
}Не потрібно явно передавати тип, якщо TypeScript коректно його виводить:
<List<User>
items={users}
getKey={(user) => user.id}
renderItem={(user) => user.name}
/>У більшості випадків достатньо:
<List
items={users}
getKey={(user) => user.id}
renderItem={(user) => user.name}
/>Явний тип варто додавати тоді, коли він справді допомагає уникнути неоднозначності або працює з порожніми даними.
Generic-компонент описує універсальну структуру для різних типів даних.
Тип T можна використовувати в масивах, пропсах і callback-функціях.
TypeScript часто автоматично виводить T із переданих властивостей.
T extends SomeType обмежує допустимі типи та гарантує наявність потрібних властивостей.
keyof T дозволяє типізувати ключі об’єкта.
T[keyof T] описує тип значення будь-якої властивості.
Generic-компоненти-обгортки можуть передавати типізовані дані через children.
Для generic-компонентів краще використовувати звичайні generic-функції, а не React.FC.
any приховує помилки й нівелює переваги узагальнених типів.