TypeScript у світі React
TypeScript у світі React
П'ятниця, 17:45. Ви готуєтеся йти додому. Раптом Slack вибухає повідомленнями: "Продакшен впав!", "Користувачі не можуть залогінитись!", "Що сталося?!". Ви відкриваєте логи і бачите: Cannot read property 'id' of undefined. Виявляється, хтось змінив API контракт і передав null замість об'єкта користувача. Компоненти React радісно приняли null, рендер просп� успішно виконався, і тільки при спробі звернутися до user.id все полетіло.
TypeScript запобіг би цьому ще на етапі написання коду.
Навіщо TypeScript у React: Реальна історія
Проблема: Невидимі контракти
У JavaScript React ваші компоненти працюють на довірі:
// UserCard.jsx
function UserCard({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<span>ID: {user.id}</span>
</div>
)
}
// Де-інде в додатку
;<UserCard user={currentUser} />
Питання, на які JavaScript не може відповісти:
- Що таке
user? Об'єкт? Який саме? - Які поля він має містити?
- Що якщо
currentUserбудеnull? - Хтось створює
UserCardвперше — звідки йому знати, що передавати?
// Два тижні по тому хтось робить це:
<UserCard user={null} /> // 💥 Runtime error
// Або це:
<UserCard user={{ name: "Alice" }} /> // 💥 email undefined
// Або це:
<UserCard user={currentUser} userId={123} /> // userId ігнорується, але ніхто не помітив
interface User {
id: string;
name: string;
email: string;
}
interface UserCardProps {
user: User;
}
function UserCard({ user }: UserCardProps) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<span>ID: {user.id}</span>
</div>
);
}
// ❌ TypeScript Error: Type 'null' is not assignable to type 'User'
<UserCard user={null} />
// ❌ TypeScript Error: Property 'email' is missing
<UserCard user={{ name: "Alice" }} />
// ✅ TypeScript Success
<UserCard user={currentUser} />
Мета цього розділу
Ви навчитеся писати React код, який:
- Сам себе документує — тип замість коментарів
- Ловить помилки до commit — не в продакшені
- Дає розумний autocomplete — IDE знає, що ви хочете
- Спрощує refactoring — перейменували поле в API? TypeScript покаже всі місця, де треба оновити код
- React basics (компоненти, props, hooks)
- JavaScript ES6+ (destructuring, spread, arrow functions)
- TypeScript fundamentals (interfaces, types, generics) — див. попередні розділи
Життєвий цикл помилки: JS vs TS
Давайте подивимося, де саме TypeScript зупиняє помилки:
Ключова різниця:
- JavaScript: помилка живе тижні/місяці, поки хтось не натрапить на неї в продакшені
- TypeScript: помилка не дає вам навіть зберегти файл
Налаштування: Створюємо React + TypeScript проєкт
Варіант 1: Vite (Рекомендовано 2024+)
Створити проєкт
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
Що відбувається під капотом:
- Vite створює структуру проєкту
- Встановлює React + TypeScript dependencies
- Генерує
tsconfig.jsonз правильними опціями
Запустити dev server
npm run dev
Vite компілює TypeScript в браузері через esbuild (надшвидко).
Відкрити в IDE
Відкрийте проєкт у VS Code або WebStorm. IDE миттєво побачить TypeScript і увімкне autocomplete.
Варіант 2: Create React App (Legacy)
npx create-react-app my-app --template typescript
- Швидший cold start (секунди vs хвилини)
- Швидший HMR (Hot Module Replacement)
- Менший bundle size
- CRA більше не підтримується активно
Анатомія tsconfig.json: Чому кожна опція важлива
Коли ви створюєте React + TS проєкт, генерується tsconfig.json. Давайте розберемо ключові опції через призму проблем, які вони вирішують.
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"module Resolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}
::accordion-item{label=""jsx": "react-jsx" — Нова JSX трансформація"}
Проблема (React 16 і старіше):
// Потрібно було імпортувати React в кожному файлі
import React from 'react'
function App() {
return <div>Hello < /div>; / / JSX
}
Якщо ви забували import React, отримували помилку: 'React' is not defined.
Рішення (React 17+):
// Імпорт не потрібен! 🎉
function App() {
return <div>Hello</div>;
}
Опція "jsx": "react-jsx" вмикає нову трансформацію. React автоматично імпортується під капотом.
::
::accordion-item{label=""strict": true — Суворий режим (КРИТИЧНО)"}
Ця опція вмикає всі строгі перевірки TypeScript одразу:
strictNullChecks— ловить помилки зnull/undefinedstrictFunctionTypes— перевіряє коректність типів функційstrictBindCallApply— перевіряєbind,call,apply- ...та інші
Без strict: true:
const user: User = null // ✅ OK (але це помилка!)
console.log(user.name) // 💥 Runtime error
З strict: true:
const user: User = null // ❌ Error: Type 'null' is not assignable to type 'User'
strict: false, ви втрачаєте 80% переваг TypeScript. Завжди використовуйте strict: true.::
::accordion-item{label=""noUnusedLocals" та "noUnusedParameters" — Чистота коду"}
Без цих опцій:
function calculate(a: number, b: number, c: number) {
return a + b // c не використовується, але TypeScript мовчить
}
Через місяць ви забули, навіщо c, але видалити боїтеся — а раптом він десь використовується?
З опціями:
// ❌ Error: 'c' is declared but its value is never read
function calculate(a: number, b: number, c: number) {
return a + b
}
TypeScript каже: "Або використовуй, або видали".
::
::accordion-item{label=""noFallthroughCasesInSwitch" — Ловимо забуті break"}
Класична помилка:
switch (status) {
case 'loading':
showSpinner()
// Забули break!
case 'success':
showData()
break
}
При status = 'loading' виконається і showSpinner(), і showData().
TypeScript з опцією:
// ❌ Error: Fallthrough case in switch
switch (status) {
case 'loading':
showSpinner()
// TypeScript вимагає break або return
case 'success':
showData()
break
}
::
::
Типізація React компонентів: Еволюція підходів
Якщо ви читали старі туторіали, могли бачити різні способи типізації компонентів. Давайте пройдемо шлях еволюції, щоб зрозуміти, чому зараз ми пишемо саме так.
Етап 1: Class Components (2015-2018)
interface ButtonProps {
label: string;
onClick: () => void;
}
class Button extends React.Component<ButtonProps> {
render() {
return <button onClick={this.props.onClick}>{this.props.label}</button>;
}
}
Проблеми: багато boilerplate, складність з this, погана performance.
Етап 2: React.FC (2019-2021)
import { FC } from 'react';
interface ButtonProps {
label: string;
onClick: () => void;
}
const Button: FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
Що дав FC:
- ✅ Автоматичний тип повернення (
JSX.Element) - ✅ Автоматично додається
children?: ReactNode
Проблема:
До React 18, FC завжди додавав children, навіть якщо компонент їх не приймав:
const Button: FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
// ✅ TypeScript не скаржиться (але це помилка!)
<Button label="Click" onClick={() => {}}>
This will be ignored!
</Button>
Етап 3: Явна типізація (2024+, рекомендовано)
interface ButtonProps {
label: string;
onClick: () => void;
}
function Button({ label, onClick }: ButtonProps): JSX.Element {
return <button onClick={onClick}>{label}</button>;
}
Переваги:
- ✅ Повна прозорість — ви бачите всі типи
- ✅ Ви контролюєте, чи приймає компонент
children - ✅ Немає магії від
FC
FC — це legacy підхід, який залишився зі старих навичок.Props з children: Три підходи
Часто компонент має приймати вкладені елементи:
<Card title="User Info">
<p>Name: Alice</p>
<p>Email: alice@example.com</p>
</Card>
Як типізувати children?
import { ReactNode } from 'react';
interface CardProps {
title: string;
children: ReactNode; // Найбільш гнучкий тип
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
Коли використовувати: Коли ви хочете бачити children явно в інтерфейсі.
import { PropsWithChildren } from 'react';
interface CardProps {
title: string;
}
function Card({ title, children }: PropsWithChildren<CardProps>) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
Що робить PropsWithChildren:
type PropsWithChildren<P> = P & { children?: ReactNode }
Просто додає children?: ReactNode до ваших пропсів.
Коли використовувати: Коли не хочете писати children вручну кожного разу.
interface ButtonProps {
label: string;
onClick: () => void;
}
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
// ❌ TypeScript Error: Property 'children' does not exist
<Button label="Click" onClick={() => {}}>
Invalid children
</Button>
Якщо ви не додаєте children до типу, TypeScript забороняє їх передавати.
Що таке ReactNode?
ReactNode — це Union тип, який включає все, що можна рендерити у React:
type ReactNode =
| ReactElement // <div>, <MyComponent />
| string // "Hello"
| number // 42
| boolean // true, false (рендеріться як порожнє)
| null // (рендеріться як порожнє)
| undefined // (рендеріться як порожнє)
| ReactNode[] // [<div />, "text", 123]
| ReactPortal // Портали React
Приклади:
function Display({ content }: { content: ReactNode }) {
return <div>{content}</div>;
}
<Display content="Text" /> // ✅ string
<Display content={42} /> // ✅ number
<Display content={<span>Hi</span>} /> // ✅ ReactElement
<Display content={null} /> // ✅ null (нічого не рендериться)
<Display content={[1, "two", <div />]} />// ✅ array
Props Patterns: Від простого до професійного
Давайте побудуємо компонент кнопки, поступово ускладнюючи його та вивчаючи нові патерни.
Рівень 1: Базові пропси
interface ButtonProps {
label: string;
onClick: () => void;
}
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
// Використання
<Button label="Submit" onClick={() => console.log('Clicked')} />
Проблема: Що якщо ми хочемо передати стандартні HTML атрибути? disabled, type, aria-label?
Рівень 2: Додаємо опціональні пропси
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
className?: string;
}
function Button({ label, onClick, disabled, type = 'button', className }: ButtonProps) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={className}
>
{label}
</button>
);
}
Проблема: Якщо HTML button має 40+ атрибутів, ми будемо додавати їх всі вручну?
Рівень 3: ComponentProps — наслідування всіх HTML атрибутів
import { ComponentProps } from 'react';
type ButtonProps = ComponentProps<'button'> & {
variant?: 'primary' | 'secondary' | 'danger';
};
function Button({ variant = 'primary', className, ...props }: ButtonProps) {
const variantClass = `btn-${variant}`;
const combinedClassName = [variantClass, className].filter(Boolean).join(' ');
return <button className={combinedClassName} {...props} />;
}
// Тепер працюють ВСІ атрибути button:
<Button
variant="primary"
onClick={() => {}}
disabled
aria-label="Close dialog"
type="submit"
onMouseEnter={() => {}}
>
Click me
</Button>
Магія ComponentProps<'button'>:
TypeScript витягує всі стандартні атрибути HTML елемента <button>:
onClick,onMouseEnter,onFocus...disabled,type,form...aria-*,data-*...className,style,id...
ComponentProps для компонентів, які є обгортками навколо HTML елементів. Це економить час та робить компонент максимально гнучким.interface ButtonProps {
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit'
className?: string
id?: string
// ... ще 35 атрибутів
}
type ButtonProps = ComponentProps<'button'> & {
variant?: 'primary' | 'secondary'
}
useState: Від автоматичного inference до складних станів
Hook useState — найчастіше використовуваний hook у React. TypeScript чудово виводить типи автоматично, але є нюанси.
Сценарій 1: Автоматичний inference (найпростіший кейс)
function Counter() {
// ✅ TypeScript знає: count — це number
const [count, setCount] = useState(0);
// ✅ TypeScript знає: name — це string
const [name, setName] = useState('');
// ✅ TypeScript знає: isOpen — це boolean
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
{/* ❌ TypeScript Error: Argument of type 'string' is not assignable to parameter of type 'number' */}
<button onClick={() => setCount('invalid')}>Wrong</button>
</div>
);
}
Правило: Якщо початкове значення — не null/undefined, TypeScript виведе тип автоматично.
Сценарій 2: Початкове значення — null (потрібен Generic)
interface User {
id: string
name: string
email: string
}
function UserProfile() {
// ❌ Без Generic: user має тип 'null' назавжди
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then((data) => {
// ❌ Error: Type '{ id: string; name: string; ... }' is not assignable to type 'null'
setUser(data)
})
}, [])
return null
}
Рішення — Явний Generic:
function UserProfile() {
// ✅ Явно вказуємо: user може бути User або null
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then(data => {
setUser(data); // ✅ OK
});
}, []);
// ✅ Type narrowing через if
if (!user) {
return <div>Loading...</div>;
}
// Тут TypeScript знає: user — це User (не null)
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Сценарій 3: Складні стани — Boolean Flags vs Discriminated Union
Уявіть, що завантажуєте дані з API. Є три можливі стани:
- Завантаження (loading)
- Успіх (success) з даними
- Помилка (error) з повідомленням
❌ Поганий спосіб: B oolean прапорці
function UserList() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [users, setUsers] = useState<User[] | null>(null)
// ❌ Можливі НЕВАЛІДНІ стани:
// isLoading: true, error: "Failed", users: [...]
// isLoading: false, error: null, users: null (що показувати?)
// isLoading: true, error: null, users: [...] (завантаження з даними?!)
}
Проблема: Ці три змінні незалежні, тому можуть опинитися в суперечливих станах.
✅ Хороший спосіб: Discriminated Union
type DataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function UserList() {
const [state, setState] = useState<DataState<User[]>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'loading' });
fetchUsers()
.then(data => setState({ status: 'success', data }))
.catch(err => setState({ status: 'error', error: err.message }));
}, []);
// ✅ Type narrowing через switch
switch (state.status) {
case 'idle':
return <div>Click to load</div>;
case 'loading':
return <div>Loading...</div>;
case 'success':
// TypeScript знає: state.data існує
return <ul>{state.data.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
case 'error':
// TypeScript знає: state.error існує
return <div>Error: {state.error}</div>;
}
}
Чому це краще:
useRef: DOM маніпуляції та мутабельні значення
useRef має два абсолютно різні use case, і типізація відрізняється.
Use Case 1: Доступ до DOM елементів
import { useRef } from 'react';
function AutoFocusInput() {
// ✅ Тип: RefObject<HTMLInputElement | null>
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
// ⚠️ current може бути null (елемент ще не змонтований)
inputRef.current?.focus(); // Optional chaining
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
</>
);
}
Чому null як початкове значення?
На момент виклику useRef, компонент ще не змонтований → DOM елемент не існує → ref.current буде null до першого рендеру.
React монтує компонент
const inputRef = useRef<HTMLInputElement>(null)
// ref.current === null
React рендерить JSX і прикріплює ref
<input ref={inputRef} />
// Під капотом React робить: inputRef.current = <DOM node>
Тепер ref.current містить реальний DOM елемент
inputRef.current?.focus() // ✅ Працює
Use Case 2: Зберігання мутабельних значень (не DOM)
Іноді потрібно зберегти значення, яке:
- Не повинно викликати ре-рендер при зміні
- Має зберігатися між рендерами
- Можна змінювати безпосередньо
Приклад: Interval ID
function Timer() {
const [seconds, setSeconds] = useState(0);
// ✅ Зберігаємо ID інтервалу (не DOM ref!)
const intervalRef = useRef<number | null>(null);
const startTimer = () => {
// Якщо вже запущено, не запускаємо знову
if (intervalRef.current !== null) return;
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
};
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={start Timer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}
Чому не useState?
Якби ми використали const [intervalId, setIntervalId] = useState(null), кожна зміна викликала б ре-рендер. Ref дозволяє змінювати значення без ре-рендеру.
const intervalRef = useRef<number | null>(null)
intervalRef.current = 123 // Без ре-рендеру
const [intervalId, setIntervalId] = useState<number | null>(null)
setIntervalId(123) // Викликає ре-рендер (не потрібно)
Events: Типізація подій у React
Ось де новачки найчастіше стикаються з TypeScript помилками.
Проблема: Generic EventTarget
function MyForm() {
const handleChange = (e) => {
// ❌ Error: Parameter 'e' implicitly has an 'any' type
console.log(e.target.value);
};
return <input onChange={handleChange} />;
}
TypeScript не знає, що таке e. Треба вказати тип явно.
Рішення: Використати правильний Event тип
React має спеціальні типи для всіх подій:
<input>, <textarea>, <select> (зміна значення)import { ChangeEvent } from 'react'
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value) // ✅ string
}
<form> submitimport { FormEvent } from 'react'
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
}
import { MouseEvent } from 'react'
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log(e.clientX, e.clientY) // Координати миші
}
import { KeyboardEvent } from 'react'
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
console.log('Enter pressed!')
}
}
onFocus, onBlurimport { FocusEvent } from 'react'
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
console.log('Input lost focus')
}
Event Flow у React + TypeScript
Практичний приклад: Форма з типізацією
Давайте створимо форму логіну з повною типізацією:
import { FormEvent, ChangeEvent, useState } from 'react';
interface FormData {
email: string;
password: string;
}
interface FormErrors {
email?: string;
password?: string;
}
function LoginForm() {
const [formData, setFormData] = useState<FormData>({
email: '',
password: '',
});
const [errors, setErrors] = useState<FormErrors>({});
// ✅ Типізація: ChangeEvent для input
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// TypeScript знає: name — це keyof FormData
setFormData(prev => ({ ...prev, [name]: value }));
// Очищаємо помилку при зміні поля
if (errors[name as keyof FormErrors]) {
setErrors(prev => ({ ...prev, [name]: undefined }));
}
};
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.email.includes('@')) {
newErrors.email = 'Email має містити @';
}
if (formData.password.length < 6) {
newErrors.password = 'Пароль має містити мінімум 6 символів';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// ✅ Типізація: FormEvent для form submit
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (validate()) {
console.log('Форма валідна:', formData);
// Тут можна відправити дані на сервер
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
</div>
<div>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && <span style={{ color: 'red' }}>{errors.password}</span>}
</div>
<button type="submit">Login</button>
</form>
);
}
CSSProperties та Style типізація
Inline styles з TypeScript
import { CSSProperties } from 'react';
interface BoxProps {
style?: CSSProperties;
children: ReactNode;
}
function Box({ style, children }: BoxProps) {
const defaultStyle: CSSProperties = {
padding: '20px',
borderRadius: '8px',
backgroundColor: '#f0f0f0',
};
return <div style={{ ...defaultStyle, ...style }}>{children}</div>;
}
// Використання
<Box style={{ backgroundColor: 'blue', fontSize: '18px' }}>
Content
</Box>
// ❌ TypeScript Error: Type 'number' is not assignable to type 'string | undefined'
<Box style={{ padding: 20 }}>Invalid</Box>
Що таке CSSProperties?
Це TypeScript тип з React, який містить всі валідні CSS властивості у camelCase форматі:
const styles: CSSProperties = {
backgroundColor: 'red', // background-color
marginTop: '10px', // margin-top
fontSize: '16px', // font-size
zIndex: 10, // z-index може бути number
}
Реальний кейс: Todo App з TypeScript
Давайте об'єднаємо все, що вивчили, у повноцінному додатку.
import { useState, ChangeEvent, FormEvent, KeyboardEvent } from 'react';
// ========== TYPES ==========
interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: Date;
}
type Filter = 'all' | 'active' | 'completed';
// ========== COMPONENT ==========
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const [inputValue, setInputValue] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// ===== HANDLERS =====
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (inputValue.trim() === '') return;
const newTodo: Todo = {
id: crypto.randomUUID(),
text: inputValue,
completed: false,
createdAt: new Date(),
};
setTodos(prev => [...prev, newTodo]);
setInputValue('');
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setInputValue('');
}
};
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// ===== COMPUTED =====
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true; // 'all'
});
const stats = {
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length,
};
// ===== RENDER =====
return (
<div className="todo-app">
<h1>Todo App</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
<div className="filters">
<button onClick={() => setFilter('all')}>
All ({stats.total})
</button>
<button onClick={() => setFilter('active')}>
Active ({stats.active})
</button>
<button onClick={() => setFilter('completed')}>
Completed ({stats.completed})
</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;
Що ми використали:
- ✅
interface Todo— структура даних - ✅
type Filter— union тип для фільтрів - ✅
useState<Todo[]>— явна типізація масиву - ✅
ChangeEvent,FormEvent,KeyboardEvent— типізація подій - ✅ Type-safe callbacks —
toggleTodo(id: string)
Типові помилки та їх рішення
::accordion-item{label="Помилка 1: "Type 'string' is not assignable to type 'never'""}
// ❌ Проблема
const [data, setData] = useState({})
data.name = 'Alice' // Error!
Причина: TypeScript вивів тип {} (порожній об'єкт), який не може мати властивостей.
Рішення:
// ✅ Явно вказати тип
interface Data {
name: string
}
const [data, setData] = useState<Data>({ name: '' })
data.name = 'Alice' // OK
::
::accordion-item{label="Помилка 2: "Object is possibly 'null'""}
// ❌ Проблема
const ref = useRef<HTMLDivElement>(null)
ref.current.scrollIntoView() // Error!
Причина: ref.current може бути null до монтування.
Рішення 1: Optional chaining
ref.current?.scrollIntoView()
Рішення 2: Guard clause
if (ref.current) {
ref.current.scrollIntoView()
}
Рішення 3: Non-null assertion (якщо впевнені)
ref.current!.scrollIntoView() // ⚠️ Використовуйте обережно!
::
::accordion-item{label="Помилка 3: "Property 'value' does not exist on type 'EventTarget'""}
// ❌ Проблема
const handleChange = (e) => {
console.log(e.target.value) // Error!
}
Причина: TypeScript не знає тип події.
Рішення:
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value) // ✅ OK
}
::
::accordion-item{label="Помилка 4: "Cannot find name 'JSX'""}
Причина: Не налаштовано tsconfig.json.
Рішення:
{
"compilerOptions": {
"jsx": "react-jsx" // або "react-jsxdev"
}
}
::
Best Practices: Як писати чистий React + TS код
1. Завжди використовуйте strict: true
Без цього ви втрачаєте 80% переваг TypeScript.
{
"compilerOptions": {
"strict": true
}
}
2. Уникайте any
Кожен any — це "дірка" у вашій типовій безпеці.
// ❌ Погано
const data: any = fetchData()
// ✅ Добре
const data: unknown = fetchData()
if (typeof data === 'object') {
/* ... */
}
3. Не типізуйте очевидне
TypeScript чудово виводить типи.
// ❌ Надлишково
const count: number = 0
// ✅ Краще
const count = 0 // TypeScript знає: number
4. Використовуйте Utility Types
Partial, Pick, Omit, Record — ваші друзі.
interface User {
id: string
name: string
email: string
}
// Тільки name та email
type UserForm = Omit<User, 'id'>
// Всі поля опціональні
type PartialUser = Partial<User>
5. Discriminated Unions для стану
Замість boolean прапорців.
// ❌ Погано
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
// ✅ Добре
type State = { status: 'loading' } | { status: 'error'; error: string } | { status: 'success'; data: Data }
6. Props типи поряд із компонентом
Не створюйте types/ для всього.
// ✅ У тому ж файлі
interface ButtonProps {
label: string
}
function Button({ label }: ButtonProps) {
// ...
}
Cheat Sheet: Імпорти з React
import {
// === Типи компонентів ===
FC, // React.FC<Props> (legacy)
ComponentType, // Тип будь-якого компонента
ComponentProps, // Витягує props HTML елемента
// === Типи пропсів ===
PropsWithChildren, // Додає children?: ReactNode
ReactNode, // Все, що можна рендерити
ReactElement, // Результат JSX (<div />)
// === Події ===
ChangeEvent, // onChange для inputs
FormEvent, // onSubmit для forms
MouseEvent, // onClick, onMouseEnter...
KeyboardEvent, // onKeyDown, onKeyUp...
FocusEvent, // onFocus, onBlur
// === Hooks ===
Dispatch, // Тип для setX з useState
SetStateAction, // Тип аргументу для setX
MutableRefObject, // Тип ref для мутабельних значень
RefObject, // Тип ref для DOM елементів
// === Стилі ===
CSSProperties, // Тип для style prop
// === Інше ===
JSX, // Namespace з JSX типами
} from 'react'
Підсумок
Ви навчилися:
- ✅ Типізувати функціональні компоненти через явну типізацію (сучасний підхід) замість
FC - ✅ Працювати з props: опціональні поля, деструктуризація,
ComponentPropsдля наслідування HTML атрибутів - ✅ Типізувати hooks (
useState,useRef) з автоматичним inference та явними Generic - ✅ Використовувати Discriminated Union для складних станів замість boolean flags
- ✅ Правильно типізувати React події:
ChangeEvent,MouseEvent,FormEvent,KeyboardEvent - ✅ Застосовувати Utility Types (
ComponentProps,PropsWithChildren,CSSProperties) - ✅ Розуміти різницю між
interfaceтаtypeта коли кожен використовувати - ✅ Уникати типових помилок з
null,any, events