- Sprint1
- Sprint2
- Sprint3
- Sprint4
- Sprint5
- Sprint6
- Sprint7
- Sprint8
- Sprint9
- Sprint10
- Sprint11
- Sprint12
- Sprint13
- Sprint14
- Sprint15
- Sprint16
- Sprint17
- Sprint18
JavaScript: типи даних та масиви
Ця сторінка — навчальна шпаргалка з JavaScript. Вона створена для повторення базових тем перед вивченням React.
1. Типи даних у JavaScript
У JavaScript існує 8 основних типів даних.
Примітивні типи
- string — рядки
- number — числа
- bigint — дуже великі цілі числа
-
boolean —
true/false - undefined — значення не присвоєне
- null — навмисно порожнє значення
- symbol — унікальний ідентифікатор
let title = "React";
let count = 10;
let isActive = true;
let data;
let empty = null;
⚠️ Важливо: typeof null повертає
"object" — це історична помилка JavaScript.
Непримітивний тип
object — обʼєкти, масиви, функції.
const user = { name: "Viktor", age: 25 };
const numbers = [1, 2, 3];
2. Динамічна (weak) типізація
JavaScript має динамічну типізацію — тип змінної залежить від значення.
let x;
x = 42; // number
x = "42"; // string
x = true; // boolean
Це зручно, але може призводити до помилок, якщо не контролювати типи.
3. Приведення типів
Перетворення в String
String(20); // "20"
20 + ""; // "20"
Перетворення в Number
Number("20"); // 20
+"20"; // 20
parseInt / parseFloat
parseInt("7.5"); // 7
parseFloat("7.5"); // 7.5
parseInt("7px"); // 7
parseInt("px"); // NaN
NaN та isNaN
isNaN(1); // false
isNaN("1"); // false
isNaN("1a"); // true
isNaN(undefined); // true
⚠️ isNaN(null) повертає false.
Перетворення в Boolean
Boolean(1); // true
Boolean(0); // false
Boolean(""); // false
Boolean("JS"); // true
!!"text"; // true
!!0; // false
Falsy значення, які потрібно знати:
- false
- 0
- ""
- null
- undefined
- NaN
4. Пріоритет операторів
10 - 20 / 5; // 6
2 ** 4 * 2; // 32
Дужки змінюють порядок виконання:
let result = 2 * (4 + 2);
5. Масиви
Створення масиву
const arr = [];
const cities = ["Rome", "Lviv", "Warsaw"];
Доступ за індексом
cities[0]; // "Rome"
cities[1]; // "Lviv"
Зміна та довжина
cities[0] = "Berlin";
cities.length; // кількість елементів
6. Перебір масивів
for...of
for (let city of cities) {
// робота з city
}
for
for (let i = 0; i < cities.length; i++) {
// робота з cities[i]
}
7. Методи масивів
Мутаційні методи
cities.push("Kyiv");
cities.pop();
cities.shift();
cities.unshift("Paris");
cities.splice(1, 1);
Функціональні методи
cities.map(city => city + " Capital");
cities.filter(city => city.length < 6);
const numbers = [2, 4, 6, 8];
numbers.reduce((sum, value) => sum + value, 0);
Саме map / filter / reduce — основа роботи з даними в React.
8. Висновки
- JavaScript має слабку динамічну типізацію
- Приведення типів потрібно розуміти, а не вгадувати
- Масиви — ключова структура для UI
- Методи масивів — фундамент React
Практика JavaScript: типи даних та масиви
Нижче наведено три великі комплексні практичні завдання (міні-проєкти). Вони охоплюють увесь матеріал теми: типи даних, приведення типів, масиви, методи масивів, роботу з DOM та BOM.
🧩 Завдання 1. Менеджер міст (Cities Manager)
Мета: реалізувати керування списком міст з відображенням статистики.
Інтерфейс
- Поле введення назви міста
- Кнопка Add city
- Список міст
- Блок зі статистикою
Вимоги
- Міста зберігаються в масиві рядків
-
Значення з input приводити до типу
string - Порожній рядок додавати заборонено
- Після додавання поле очищується
- Кожне місто має кнопку Delete
- Видалення реалізувати через
splice
Статистика (відображати в DOM)
- Загальна кількість міст
- Кількість міст з довжиною назви менше 6 символів
- Список усіх міст через кому
Що відпрацьовується
- Тип
string - Приведення типів
- Масиви
-
push,splice,filter,length - Робота з DOM
🧩 Завдання 2. Аналіз числових даних
Мета: обробка та аналіз числових значень, введених користувачем.
Інтерфейс
- Поле введення числа
- Кнопка Add number
- Список введених чисел
- Блок аналітики
Вимоги
- Всі значення зберігати в масиві чисел
-
Використовувати
Number()для приведення типу -
Якщо значення
NaN— показати повідомлення в DOM -
null,\"\",undefinedобробити окремо -
Заборонено використовувати
for
Аналітика (оновлюється після кожної дії)
- Кількість чисел
- Сума всіх значень
- Середнє арифметичне
- Масив додатних чисел
- Новий масив, де всі числа помножені на 2
Що відпрацьовується
Number(),isNaN()- Truthy / falsy значення
-
map,filter,reduce - Робота з DOM
🧩 Завдання 3. Панель стану застосунку
Мета: відображення та керування станом застосунку через інтерфейс.
Початкові дані (BOM)
- Ширина та висота вікна браузера
- Поточний час
Кнопки керування
- Add random number — додає випадкове число (0–100)
- Clear data — очищає масив
Панель стану (DOM)
- Кількість елементів
- Мінімальне та максимальне значення
- Сума всіх чисел
- Булевий прапорець
isEmpty
Логічні умови
isEmpty === true, якщо масив порожній- Всі обчислення виконувати через методи масивів
- При зміні розміру вікна оновлювати інформацію
Що відпрацьовується
- Тип
boolean - Оператори порівняння
Mathreduce- BOM та DOM
Object-Oriented Design (OOD)
Object-Oriented Design — це підхід до проєктування коду, який допомагає створювати масштабовані, зрозумілі та підтримувані застосунки. Для React це критично важливо, бо компоненти — це теж обʼєкти з відповідальністю.
Coupling та Cohesion
Coupling (звʼязність)
Coupling показує, наскільки сильно один модуль залежить від іншого. Чим менше модуль знає про внутрішню реалізацію інших — тим краще.
- Low Coupling — модулі слабко повʼязані (ідеально)
- High Coupling — модулі тісно повʼязані (погано)
// ❌ High Coupling
class UserService {
constructor() {
this.apiUrl = "https://example.com/api/users";
}
getUsers() {
// Жорстка привʼязка до конкретного API
fetch(this.apiUrl).then();
}
}
// ✅ Low Coupling
class ApiClient {
fetchData(url) {
return fetch(url);
}
}
class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
getUsers() {
// UserService не знає, як саме працює API
this.apiClient.fetchData("/users");
}
}
Cohesion (згуртованість)
Cohesion показує, наскільки методи класу повʼязані між собою логічно. Один клас — одна відповідальність.
// ❌ Low Cohesion
class Editor {
editText() {}
printBook() {}
sendEmails() {}
}
// ✅ High Cohesion
class TextEditor {
editText() {}
formatText() {}
saveDraft() {}
}
Composition
Composition — це коли один обʼєкт містить інший і не може існувати без нього. У React це схоже на вкладені компоненти.
class Engine {
start() {
console.log("Engine started");
}
}
class Car {
constructor() {
// Двигун є частиною машини
this.engine = new Engine();
}
drive() {
this.engine.start();
console.log("Car is driving");
}
}
const car = new Car();
car.drive();
Aggregation
Aggregation — слабший звʼязок. Обʼєкти можуть існувати незалежно.
class Book {
constructor(title) {
this.title = title;
}
}
class Library {
constructor() {
this.books = [];
}
addBook(book) {
this.books.push(book);
}
}
const book1 = new Book("Clean Code");
const library = new Library();
library.addBook(book1);
Interfaces та Generics (концептуально)
В JavaScript немає інтерфейсів, але ми можемо мислити інтерфейсами — домовленостями про методи.
// Псевдо-інтерфейс через домовленість
class PaymentService {
pay(amount) {
throw new Error("Method must be implemented");
}
}
class PayPalPayment extends PaymentService {
pay(amount) {
console.log(`Paid ${amount} via PayPal`);
}
}
Modularity
Модульність — поділ коду на незалежні файли. Це фундамент React і сучасного JS.
// greeting.js
export function sayHi(name) {
console.log(`Hello, ${name}`);
}
// main.js
import { sayHi } from "./greeting.js";
sayHi("Peter");
Object-Oriented Programming (OOP)
OOP — це стиль програмування, який допомагає описувати реальний світ за допомогою обʼєктів. У React це напряму впливає на логіку компонентів, сервісів та бізнес-логіки.
Inheritance (Наслідування)
class Vehicle {
constructor(kind) {
this.kind = kind;
}
drive() {
console.log(`${this.kind} is driving`);
}
}
class Car extends Vehicle {
carryPassengers() {
console.log(`${this.kind} carries passengers`);
}
}
const car = new Car("Minivan");
car.drive();
car.carryPassengers();
super та перевизначення методів
class Vehicle {
drive() {
console.log("Vehicle is driving");
}
}
class Car extends Vehicle {
drive() {
// Виклик методу батьківського класу
super.drive();
console.log("Car drives fast");
}
}
const car = new Car();
car.drive();
Prototypal Inheritance
До ES6 JavaScript використовував прототипи напряму. Важливо розуміти це для глибшого розуміння мови.
function Animal(name) {
this.name = name;
}
Animal.prototype.sayHi = function () {
console.log(this.name);
};
function Dog(name) {
Animal.call(this, name);
}
// Наслідування прототипу
Dog.prototype = Object.create(Animal.prototype);
const dog = new Dog("Buddy");
dog.sayHi();
Polymorphism
class Shape {
draw() {
console.log("Drawing shape");
}
}
class Circle extends Shape {
draw() {
console.log("Drawing circle");
}
}
const shapes = [new Shape(), new Circle()];
shapes.forEach(shape => shape.draw());
Encapsulation
Через замикання
class Counter {
constructor() {
let value = 0; // приватна змінна
this.increment = function () {
value++;
return value;
};
}
}
const counter = new Counter();
counter.increment();
Через private fields
class BankAccount {
#balance = 0; // приватне поле
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount();
account.deposit(100);
account.getBalance();
Abstraction
Абстракція дозволяє приховати деталі реалізації і показати лише необхідний інтерфейс.
class AuthService {
login() {
this.validate();
this.sendRequest();
}
validate() {
console.log("Validation");
}
sendRequest() {
console.log("Request sent");
}
}
const auth = new AuthService();
auth.login();
Практичне завдання 1 — Менеджер користувачів (OOP + Cohesion)
Реалізуй менеджер користувачів з використанням класів, інкапсуляції та роботи з DOM.
Функціональні вимоги
-
Створити клас
Userз властивостямиnameтаage -
Створити клас
UserManager, який:- зберігає масив користувачів
- додає нового користувача
- видаляє користувача
- підраховує статистику
- Використовувати інкапсуляцію (приватні поля або замикання)
- Заборонити додавання користувачів з однаковими іменами
UI
- Input для імені
- Input для віку
- Кнопка "Add user"
- Список користувачів з кнопкою "Delete"
Статистика (DOM)
- Загальна кількість користувачів
- Середній вік
- Список користувачів старших за 18
❗ Заборонено використовувати console.log для
результатів
Практичне завдання 2 — Система платежів (Polymorphism + Abstraction)
Створи систему обробки платежів з використанням поліморфізму та абстракції.
Функціональні вимоги
-
Створити базовий клас
Paymentз методомpay(amount) -
Створити мінімум 2 спадкоємці:
CardPaymentPayPalPayment
-
Кожен тип платежу має власну реалізацію
pay - Використати поліморфізм при обробці платежу
UI
- Select з вибором типу платежу
- Input для суми
- Кнопка "Pay"
Результат (DOM)
- Повідомлення про успішну оплату
- Тип платежу
- Сума
💡 Клас, який працює з UI, не повинен знати деталі реалізації платежу
Практичне завдання 3 — Аналітика випадкових даних (Composition + BOM)
Реалізуй систему збору та аналізу випадкових чисел з використанням композиції та BOM.
Функціональні вимоги
-
Створити клас
RandomNumberService, який генерує числа -
Створити клас
Statistics, який:- рахує мінімум
- максимум
- суму
- Головний клас повинен використовувати ці сервіси (composition)
UI
- Кнопка "Add random number"
- Кнопка "Clear"
Аналітика (DOM)
- Кількість чисел
- Мінімальне число
- Максимальне число
- Сума чисел
BOM
- Відображати поточний час (оновлення щосекунди)
- Відображати розмір вікна браузера
- Оновлювати розміри при
resize
🔥 Всі обчислення — через класи, UI лише відображає результат
Asynchronous JavaScript
Асинхронність у JavaScript дозволяє виконувати довготривалі операції (таймери, запити до сервера, роботу з файлами) без блокування основного потоку виконання. Це критично важливо для браузера, щоб інтерфейс залишався responsive.
Event Loop (коротко і по суті)
JavaScript однопоточний, але асинхронність досягається за рахунок Event Loop. Синхронний код виконується одразу, а асинхронні задачі потрапляють у черги (macrotask і microtask) та виконуються пізніше.
console.log("start");
setTimeout(() => {
console.log("timeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("end");
// Порядок виводу:
// start
// end
// promise
// timeout
Callbacks
Callback — це функція, яка передається як аргумент і викликається після завершення асинхронної операції.
function loadData(callback) {
setTimeout(() => {
callback("Data loaded");
}, 1000);
}
loadData((result) => {
console.log(result);
});
❌ Мінус callback-підходу — складна вкладеність (callback hell).
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
console.log("Callback hell");
}, 1000);
}, 1000);
}, 1000);
Promises
Promise — це обʼєкт, який представляє результат асинхронної операції в майбутньому. Promise може бути в одному з трьох станів: pending, fulfilled або rejected.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Success");
}, 1000);
});
promise.then((result) => {
console.log(result);
});
.then(), .catch(), .finally()
fetch("/api/data")
.then((response) => response.json())
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error("Error:", error);
})
.finally(() => {
console.log("Request finished");
});
✔ .then завжди повертає новий Promise
✔ .catch ловить помилки з усього ланцюжка
✔ .finally виконується завжди
Promise.all та Promise.race
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
Promise.all([p1, p2, p3]).then((values) => {
console.log(values); // [1, 2, 3]
});
Promise.race([p1, p2, p3]).then((value) => {
console.log(value); // перший виконаний
});
Async / Await
async/await — це синтаксичний цукор над Promise, який дозволяє писати асинхронний код як синхронний.
async function loadUser() {
try {
const response = await fetch("/api/user");
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
loadUser();
Паралельний await
async function loadAll() {
const [users, posts] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
]);
console.log(users, posts);
}
JavaScript Generators
Generator — це функція, яка може призупиняти своє виконання і відновлювати його пізніше. Вона повертає ітератор.
Базовий генератор
function* numbersGenerator() {
yield 1;
yield 2;
yield 3;
}
const gen = numbersGenerator();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }
for...of та spread
function* letters() {
yield "a";
yield "b";
yield "c";
}
for (const letter of letters()) {
console.log(letter);
}
const arr = [...letters()];
console.log(arr); // ["a", "b", "c"]
yield*
function* inner() {
yield 2;
yield 3;
}
function* outer() {
yield 1;
yield* inner();
yield 4;
}
console.log([...outer()]); // [1, 2, 3, 4]
Нескінченні генератори
function* infiniteCounter() {
let i = 0;
while (true) {
yield i++;
}
}
const counter = infiniteCounter();
console.log(counter.next().value); // 0
console.log(counter.next().value); // 1
Коли використовувати генератори
- Лінива ітерація даних
- Нескінченні послідовності
- Складна покрокова логіка
- Контроль потоку виконання
Підсумок
Асинхронність — одна з найважливіших тем у JavaScript. Promise та async/await — маст-хев для сучасного фронтенду, а генератори — потужний, але більш нішевий інструмент, який варто знати для глибокого розуміння мови.
Практичне завдання 1 — Асинхронний менеджер користувачів (Promise + async/await)
Реалізуй менеджер користувачів, який асинхронно отримує, додає та видаляє користувачів, і відображає результат через DOM.
Мета завдання
Закріпити роботу з Promise,
async / await, обробку помилок та оновлення
інтерфейсу без використання console.log.
Функціональні вимоги
-
Створити клас
UserService, який:- асинхронно повертає список користувачів (через Promise)
- асинхронно додає нового користувача
-
імітує затримку сервера через
setTimeout
-
Використати
async / awaitдля взаємодії з сервісом - Обробити помилки через
try / catch
UI
- Input для імені користувача
- Кнопка "Load users"
- Кнопка "Add user"
- Список користувачів
DOM-результат
- Стан завантаження (Loading...)
- Список користувачів після асинхронного запиту
- Повідомлення про помилки
❗ Заборонено використовувати console.log для
відображення результатів — тільки DOM
💡 Підказка: імітуй сервер через
new Promise(resolve => setTimeout(...))
Практичне завдання 2 — Асинхронна система завантаження даних (Event Loop + Promise.all)
Створи систему, яка паралельно завантажує кілька наборів даних і відображає результат у DOM.
Мета завдання
Закріпити розуміння Event Loop, Promise.all, асинхронних операцій та їх впливу на UI.
Функціональні вимоги
-
Створити асинхронні функції:
- завантаження списку користувачів
- завантаження списку платежів
- завантаження статистики
-
Кожна функція повинна повертати
Promiseз різною затримкою -
Використати
Promise.allдля паралельного завантаження
UI
- Кнопка "Load dashboard"
- Блок для користувачів
- Блок для платежів
- Блок для статистики
DOM-результат
- Загальний статус завантаження
- Окремий результат для кожного блоку
- Повідомлення у разі помилки хоча б одного Promise
❗ Заборонено використовувати console.log для
відображення результатів — тільки DOM
💡 Поясни в коді різницю між послідовним
await і
Promise.all
JSX та перші кроки з React
JSX (JavaScript XML) — це синтаксичне розширення JavaScript, яке дозволяє описувати інтерфейс користувача за допомогою HTML-подібного синтаксису.
Браузер не розуміє JSX напряму. Під час збірки JSX перетворюється у звичайний JavaScript-код.
Що таке JSX простими словами
JSX дозволяє писати розмітку прямо в JavaScript-файлах. Це робить код компонентів читабельнішим і логічнішим.
const element = "Hello React";
У реальному React-проєкті JSX виглядає як HTML, але технічно це частина JavaScript-коду.
Створення чистого React-проєкту через командну стрічку
Для практики JSX зручно почати з Create React App — офіційного інструменту від команди React.
node -v
npx create-react-app my-react-jsx
cd my-react-jsx
npm start
Після запуску dev-сервера застосунок відкриється за адресою http://localhost:3000
Мінімальна структура React-проєкту
my-react-jsx/
├── public/
│ └── index.html
├── src/
│ ├── App.js
│ ├── index.js
│ └── index.css
├── package.json
- public/index.html — єдиний HTML-файл
- src/index.js — точка входу React
- src/App.js — головний компонент
- src/index.css — глобальні стилі
Як React рендерить сторінку
У файлі index.html є лише один контейнер, в який React монтує весь застосунок.
<div id="root"></div>
index.js — старт застосунку
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root")
);
root.render(App());
React рендерить компонент App всередині елемента з id root.
Перший компонент App
function App() {
return "Мій перший React застосунок";
}
export default App;
Компонент — це JavaScript-функція, яка повертає опис інтерфейсу.
Базові правила JSX
- Компонент має повертати один кореневий елемент
- JSX — це JavaScript, а не HTML
- Динамічні значення вставляються через фігурні дужки
- Для класів використовується className
Стилізація React-компонентів
React не навʼязує конкретний спосіб стилізації. Можна використовувати кілька підходів.
1. Звичайні CSS-файли
.title {
color: blue;
}
import "./App.css";
✔ Просто
❌ Стилі глобальні
2. CSS Modules
.title {
color: green;
}
import styles from "./App.module.css";
✔ Ізольовані стилі
✔ Часто використовуються в реальних проєктах
3. Inline styles
const style = {
color: "red",
fontSize: "24px",
};
✔ Підходить для дрібних стилів
❌ Погано масштабується
4. Styled Components (коротко)
Підхід, при якому стилі описуються через JavaScript. Частіше використовується у великих SPA.
✔ Потужний інструмент
❌ Потрібна додаткова бібліотека
Підсумок
Для старту з React достатньо зрозуміти JSX, структуру проєкту та базовий рендер компонентів. Далі ці знання стануть фундаментом для props, state і hooks.
Коротко про Vite та Next.js
Окрім "чистого" React-проєкту існують сучасні інструменти, які спрощують розробку та покращують продуктивність. Найпопулярніші з них — Vite та Next.js.
Vite — швидкий інструмент для розробки
Vite — це сучасний збирач і dev-сервер, який фокусується на максимально швидкому старті та оновленні застосунку.
npm create vite@latest my-vite-react
cd my-vite-react
npm install
npm run dev
Основні особливості Vite:
- Миттєвий запуск dev-сервера
- Швидкий Hot Module Replacement
- Мінімальна конфігурація
- Підходить для SPA
Next.js — React-фреймворк
Next.js — це повноцінний фреймворк поверх React, який вирішує багато задач "з коробки".
npx create-next-app@latest my-next-app
cd my-next-app
npm run dev
Основні особливості Next.js:
- Файлова маршрутизація
- Server Side Rendering
- Static Site Generation
- Оптимізація зображень та шрифтів
Різниця між Create React App, Vite та Next.js
- Create React App — базовий React без фреймворків, підходить для навчання JSX та основ.
- Vite — інструмент для швидкої розробки SPA, часто використовується у сучасних проєктах.
- Next.js — фреймворк для великих продуктів, де важливі SEO, продуктивність і серверний рендеринг.
Умовні приклади використання
// React + JSX
Підходить для навчання та невеликих SPA
// Vite + React
Підходить для сучасних клієнтських застосунків
// Next.js
Підходить для сайтів, блогів, маркетингових сторінок, SaaS
Важливо памʼятати
На цьому етапі достатньо розуміти, що Vite та Next.js — це інструменти навколо React. Вони не замінюють знання JSX і базового React, а будуються поверх них.
Практика React: JSX та перші компоненти
Нижче наведено одне велике комплексне практичне завдання (міні-проєкт), яке допоможе закріпити базові знання React. Завдання охоплює створення першого React-проєкту, роботу з JSX, компонентами та базову стилізацію.
🧩 Комплексне завдання. Перший React-застосунок
Мета: створити простий React-застосунок, який складається з кількох компонентів та стилів, використовуючи лише базові можливості React і JSX.
Загальна ідея проєкту
Застосунок — це проста навчальна сторінка з заголовком, описом, списком блоків і футером. Увесь інтерфейс розбитий на окремі React-компоненти.
Підготовка проєкту
- Створи новий React-проєкт через командну стрічку
- Запусти dev-сервер і переконайся, що сторінка відкривається
- Очисти стартовий шаблон від зайвого коду
🔹 Підзавдання 1–5. Базова структура
1. Кореневий компонент
- Залиш компонент App як головний компонент застосунку
- Він має повертати JSX-розмітку
2. Заголовок сторінки
- Створи окремий компонент для заголовка
- Компонент містить назву застосунку
3. Опис застосунку
- Створи компонент з текстовим описом
- Використовуй звичайний JSX без логіки
4. Контейнер
- Обʼєднай заголовок і опис у спільний контейнер
- Контейнер — це окремий компонент
5. Кореневий елемент JSX
- Переконайся, що кожен компонент повертає один кореневий елемент
- За потреби використовуй обгортку
🔹 Підзавдання 6–10. Робота з JSX
6. JSX-вирази
- Використай змінну всередині JSX
- Встав значення через фігурні дужки
7. Атрибути в JSX
- Додай CSS-класи через className
- Не використовуй class
8. Коментарі в JSX
- Додай коментар у JSX-розмітку
- Переконайся, що він не ламає рендер
9. Статична розмітка
- Додай список з кількох пунктів
- Використовуй стандартні HTML-теги
10. Перевикористання компонентів
- Використай один і той самий компонент кілька разів
- Зверни увагу на читабельність JSX
🔹 Підзавдання 11–15. Стилізація компонентів
11. Глобальні стилі
- Використай загальний CSS-файл
- Задай базові стилі для сторінки
12. Стилі для компонентів
- Додай окремі CSS-класи для різних компонентів
- Спробуй уникати конфліктів імен
13. CSS Modules (опціонально)
- Спробуй підключити CSS Module
- Порівняй із звичайним CSS
14. Inline стилі
- Додай один елемент з inline-стилями
- Використай JavaScript-обʼєкт
15. Фінальна перевірка
- Застосунок рендериться без помилок
- Компоненти логічно розділені
- JSX читабельний і зрозумілий
Що відпрацьовується
- Створення React-проєкту
- JSX-синтаксис
- React-компоненти
- Структура застосунку
- Базова стилізація
Результат
Після виконання цього завдання ти матимеш перший повноцінний React-застосунок і чітке розуміння, як виглядає робота з JSX і компонентами.
React Keys, Hooks, Props, Events — повний гайд для Front-end розробника
React — це бібліотека для побудови інтерфейсів на основі компонентів. Щоб комфортно працювати з React, потрібно добре розуміти як передавати дані між компонентами, як обробляти події, як керувати станом і як React відстежує елементи у списках.
У цій шпаргалці зібрані чотири дуже важливі теми: Props, Events, Keys та Hooks. Це база, без якої майже неможливо писати нормальні React-застосунки.
Матеріал підійде і для звичайного React-проєкту на Vite, і для Next.js. Там, де між ними є різниця, вона буде пояснена окремо.
Що саме треба розуміти по цій темі
- Що таке props і як вони передають дані від батьківського компонента до дочірнього
- Як працюють події в React: onClick, onChange, onSubmit та інші
- Навіщо React потрібен атрибут key у списках
- Що таке hooks і як використовувати useState, useEffect, useMemo, useCallback, useRef
- У чому різниця між React на Vite і React у Next.js
- Коли компонент є клієнтським, а коли серверним у Next.js
- Які типові помилки роблять початківці
1. React Props — передача даних між компонентами
Props — це властивості, які один компонент передає іншому. Найчастіше props використовують для передачі тексту, чисел, масивів, об’єктів, функцій та JSX.
Простими словами: батьківський компонент передає дочірньому дані через атрибути, схожі на HTML-атрибути.
Найпростіший приклад props
function Greeting(props) {
return <h2>Hello, {props.name}!</h2>;
}
export default function App() {
return <Greeting name="Viktor" />;
}
Тут компонент App передає в Greeting prop з назвою name. У середині дочірнього компонента значення доступне як props.name.
Деструктуризація props
function Greeting({ name }) {
return <h2>Hello, {name}!</h2>;
}
export default function App() {
return <Greeting name="Viktor" />;
}
Це той самий приклад, але коротший і зручніший. Деструктуризація дуже часто використовується у функціональних компонентах.
Передача декількох props
function ProductCard({ title, price, inStock }) {
return (
<div>
<h2>{title}</h2>
<p>Price: {price} грн</p>
<p>Status: {inStock ? "In stock" : "Out of stock"}</p>
</div>
);
}
export default function App() {
return (
<ProductCard
title="Laptop"
price={35000}
inStock={true}
/>
);
}
Через props можна передавати не лише текст, а й числа, boolean-значення, масиви, об’єкти та навіть функції.
Передача об’єкта через props
function UserCard({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
);
}
export default function App() {
const user = {
name: "Viktor",
email: "viktor@example.com",
role: "student",
};
return <UserCard user={user} />;
}
Це поширений підхід, коли компоненту потрібно багато пов’язаних даних.
Передача масиву через props
function UserList({ users }) {
return (
<ul>
{users.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
export default function App() {
const users = [
{ id: 1, name: "Anna" },
{ id: 2, name: "Ivan" },
{ id: 3, name: "Olha" },
];
return <UserList users={users} />;
}
Тут разом з props уже з’являється і тема key, бо під час рендеру списків React просить вказувати унікальний ключ.
Передача JSX через props
function Card({ title, children }) {
return (
<div>
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
export default function App() {
return (
<Card title="Profile">
<p>This content was passed as children.</p>
</Card>
);
}
children — це спеціальний prop, який містить усе, що передано між відкриваючим і закриваючим тегом компонента.
Передача функції через props
function Button({ onAction }) {
return <button onClick={onAction}>Click me</button>;
}
export default function App() {
function handleClick() {
alert("Button clicked");
}
return <Button onAction={handleClick} />;
}
Це один з найважливіших сценаріїв. Саме так батьківський компонент дозволяє дочірньому “повідомити” про якусь дію: клік, видалення, зміни в полі вводу тощо.
Props are read-only
Props не можна змінювати всередині компонента. Вони мають сприйматись як вхідні дані. Якщо треба змінювати значення — використовують state.
function Counter({ value }) {
return <p>Current value: {value}</p>;
}
У цьому компоненті value просто читається і відображається. Змінювати його напряму не потрібно.
Типова схема роботи з props
- Батьківський компонент зберігає стан
- Передає значення вниз через props
- Передає функцію вниз через props
- Дочірній компонент викликає цю функцію при події
- Батьківський компонент оновлює стан
Повний приклад props + callback
import { useState } from "react";
function CounterControls({ onIncrement, onDecrement }) {
return (
<div>
<button onClick={onIncrement}>Increment</button>
<button onClick={onDecrement}>Decrement</button>
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(function(prevCount) {
return prevCount + 1;
});
}
function handleDecrement() {
setCount(function(prevCount) {
return prevCount - 1;
});
}
return (
<div>
<h1>Count: {count}</h1>
<CounterControls
onIncrement={handleIncrement}
onDecrement={handleDecrement}
/>
</div>
);
}
Це дуже важливий шаблон. Стан лежить у батьківському компоненті, а дочірній лише викликає функції, які цей стан змінюють.
2. React Events — обробка подій
Події у React дуже схожі на звичайний JavaScript, але мають інший синтаксис. Замість onclick використовується onClick, замість onchange — onChange.
У React події записуються у camelCase і в них передається функція, а не рядок.
Базовий onClick
export default function App() {
function handleClick() {
alert("Button was clicked");
}
return <button onClick={handleClick}>Click me</button>;
}
У onClick треба передавати посилання на функцію, а не викликати її одразу.
Неправильно і правильно
// Неправильно
<button onClick={handleClick()}>Click</button>
// Правильно
<button onClick={handleClick}>Click</button>
Якщо написати handleClick(), функція виконається ще під час рендеру, а не після натискання.
Передача аргументів в обробник
export default function App() {
function handleDelete(id) {
alert("Delete item with id: " + id);
}
return (
<button
onClick={function() {
handleDelete(5);
}}
>
Delete
</button>
);
}
Якщо треба передати аргумент, використовують обгортку у вигляді стрілочної або звичайної функції.
onChange для input
import { useState } from "react";
export default function App() {
const [text, setText] = useState("");
function handleChange(event) {
setText(event.target.value);
}
return (
<div>
<input
type="text"
value={text}
onChange={handleChange}
placeholder="Type something"
/>
<p>You typed: {text}</p>
</div>
);
}
У React поля форми часто роблять controlled components, тобто значення input контролюється через state.
onSubmit для форми
import { useState } from "react";
export default function App() {
const [email, setEmail] = useState("");
function handleSubmit(event) {
event.preventDefault();
alert("Submitted email: " + email);
}
function handleChange(event) {
setEmail(event.target.value);
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={handleChange}
placeholder="Enter email"
/>
<button type="submit">Send</button>
</form>
);
}
event.preventDefault() зупиняє стандартну поведінку форми — перезавантаження сторінки після submit.
Кілька популярних подій у React
- onClick — клік по елементу
- onChange — зміна значення input, textarea, select
- onSubmit — надсилання форми
- onFocus — фокус на елементі
- onBlur — втрата фокуса
- onKeyDown — натискання клавіші
- onMouseEnter — наведення курсора
- onMouseLeave — вихід курсора
Приклад onFocus та onBlur
import { useState } from "react";
export default function App() {
const [message, setMessage] = useState("Input is inactive");
function handleFocus() {
setMessage("Input is focused");
}
function handleBlur() {
setMessage("Input lost focus");
}
return (
<div>
<input
type="text"
onFocus={handleFocus}
onBlur={handleBlur}
/>
<p>{message}</p>
</div>
);
}
Приклад onKeyDown
import { useState } from "react";
export default function App() {
const [message, setMessage] = useState("");
function handleKeyDown(event) {
if (event.key === "Enter") {
setMessage("You pressed Enter");
}
}
return (
<div>
<input type="text" onKeyDown={handleKeyDown} />
<p>{message}</p>
</div>
);
}
Що таке Synthetic Event
У React події обгорнуті у спеціальний об’єкт — SyntheticEvent. Для більшості практичних задач це просто означає, що подія поводиться схоже в різних браузерах.
У звичайній роботі достатньо знати, що об’єкт події передається першим аргументом у функцію-обробник.
Типові помилки з подіями
- Викликають функцію одразу під час рендеру замість передачі посилання
- Забувають event.preventDefault() у формах
- Плутають value та checked для checkbox
- Пишуть HTML-стиль onclick замість React-стилю onClick
Приклад checkbox
import { useState } from "react";
export default function App() {
const [isAccepted, setIsAccepted] = useState(false);
function handleChange(event) {
setIsAccepted(event.target.checked);
}
return (
<label>
<input
type="checkbox"
checked={isAccepted}
onChange={handleChange}
/>
Accept terms
</label>
);
}
Для checkbox треба використовувати checked, а не value.
3. React Keys — ключі у списках
Коли React рендерить список елементів, він повинен розуміти, який елемент є яким між різними рендерами. Для цього і потрібен атрибут key.
key допомагає React ефективно оновлювати DOM і правильно зберігати стан елементів списку.
Базовий приклад key
export default function App() {
const users = [
{ id: 1, name: "Anna" },
{ id: 2, name: "Ivan" },
{ id: 3, name: "Olha" },
];
return (
<ul>
{users.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Найкращий key — це стабільний унікальний ідентифікатор, наприклад id з бази даних або API.
Чому не можна ігнорувати key
Якщо key немає, React показує попередження. Але проблема не лише в попередженні. Без стабільних ключів React може некоректно оновлювати список: плутати елементи, переносити стан між рядками, неправильно перерисовувати форму чи список.
Чому index як key — не найкраща ідея
const users = ["Anna", "Ivan", "Olha"];
return (
<ul>
{users.map(function(user, index) {
return <li key={index}>{user}</li>;
})}
</ul>
);
Технічно так можна, але лише в дуже простих випадках, коли список ніколи не змінює порядок, елементи не видаляються і не додаються в середину.
Якщо список динамічний, index як key може призвести до дивної поведінки інтерфейсу.
Приклад проблеми з index як key
import { useState } from "react";
function TodoItem({ text }) {
const [isDone, setIsDone] = useState(false);
function handleToggle() {
setIsDone(function(prevIsDone) {
return !prevIsDone;
});
}
return (
<li>
<label>
<input type="checkbox" checked={isDone} onChange={handleToggle} />
{text}
</label>
</li>
);
}
export default function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React" },
{ id: 2, text: "Learn Hooks" },
{ id: 3, text: "Learn Next.js" },
]);
function removeFirstItem() {
setTodos(function(prevTodos) {
return prevTodos.slice(1);
});
}
return (
<div>
<button onClick={removeFirstItem}>Remove first item</button>
<ul>
{todos.map(function(todo) {
return <TodoItem key={todo.id} text={todo.text} />;
})}
</ul>
</div>
);
}
Якщо тут замінити todo.id на index, то локальний state елементів списку може змішуватись після видалення першого елемента.
Правила для key
- Key повинен бути унікальним серед сусідніх елементів
- Key повинен бути стабільним між рендерами
- Key краще брати з даних, а не генерувати випадково під час рендеру
- Не використовуй Math.random() для key
- Не використовуй index, якщо список динамічний
Неправильно: випадковий key
{users.map(function(user) {
return <li key={Math.random()}>{user.name}</li>;
})}
Це погано, бо на кожному рендері ключі будуть новими. React сприйматиме всі елементи як повністю нові і перемонтовуватиме їх.
Де ставити key
// Правильно
{items.map(function(item) {
return <ListItem key={item.id} item={item} />;
})}
Key ставиться на елемент, який повертається безпосередньо з map().
Приклад з Fragment
import { Fragment } from "react";
export default function App() {
const users = [
{ id: 1, name: "Anna", email: "anna@example.com" },
{ id: 2, name: "Ivan", email: "ivan@example.com" },
];
return (
<div>
{users.map(function(user) {
return (
<Fragment key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</Fragment>
);
})}
</div>
);
}
Якщо не хочеш додавати зайвий div, можна використати Fragment з key.
4. React Hooks — що це таке
Hooks — це спеціальні функції React, які дозволяють використовувати state, життєвий цикл, refs, оптимізацію та інші можливості у функціональних компонентах.
Hooks починаються з префікса use. Наприклад: useState, useEffect, useRef.
Головні правила hooks
- Hooks можна викликати тільки на верхньому рівні компонента
- Hooks не можна викликати всередині if, циклів або вкладених функцій
- Hooks можна викликати тільки у React-компонентах або в custom hooks
Неправильно: hook всередині if
// Неправильно
if (isVisible) {
const [count, setCount] = useState(0);
}
Правильно: hook на верхньому рівні
const [count, setCount] = useState(0);
if (!isVisible) {
return null;
}
5. useState — локальний стан компонента
useState використовується для зберігання локального стану. Це найперший hook, який вивчають у React.
Базовий useState
import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(count + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
count — це поточне значення стану. setCount — функція для його оновлення.
Функціональне оновлення state
import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(function(prevCount) {
return prevCount + 1;
});
}
return (
<button onClick={handleIncrement}>
Count: {count}
</button>
);
}
Такий підхід безпечніший, коли новий state залежить від попереднього.
Стан для рядка
import { useState } from "react";
export default function App() {
const [name, setName] = useState("");
function handleChange(event) {
setName(event.target.value);
}
return (
<div>
<input type="text" value={name} onChange={handleChange} />
<p>Hello, {name}</p>
</div>
);
}
Стан для об’єкта
import { useState } from "react";
export default function App() {
const [user, setUser] = useState({
name: "Viktor",
age: 30,
});
function increaseAge() {
setUser(function(prevUser) {
return {
...prevUser,
age: prevUser.age + 1,
};
});
}
return (
<div>
<p>{user.name}</p>
<p>Age: {user.age}</p>
<button onClick={increaseAge}>Increase age</button>
</div>
);
}
При роботі з об’єктами та масивами треба створювати нове значення, а не мутувати старе.
Стан для масиву
import { useState } from "react";
export default function App() {
const [items, setItems] = useState(["React", "Hooks"]);
function addItem() {
setItems(function(prevItems) {
return [...prevItems, "Next.js"];
});
}
return (
<div>
<button onClick={addItem}>Add item</button>
<ul>
{items.map(function(item, index) {
return <li key={index}>{item}</li>;
})}
</ul>
</div>
);
}
Для навчального прикладу index тут допустимий, бо список просто росте в кінець. Але у реальних задачах краще мати стабільний id.
6. useEffect — побічні ефекти
useEffect використовується для побічних дій: запити до API, підписки, таймери, робота з document, localStorage та інше.
Базовий useEffect
import { useEffect } from "react";
export default function App() {
useEffect(function() {
console.log("Component mounted");
}, []);
return <h1>Hello React</h1>;
}
Порожній масив залежностей означає, що ефект виконається один раз після першого рендеру.
useEffect з залежністю
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
useEffect(function() {
document.title = "Count: " + count;
}, [count]);
return (
<button
onClick={function() {
setCount(function(prevCount) {
return prevCount + 1;
});
}}
>
Count: {count}
</button>
);
}
Тут ефект запускається після кожної зміни count.
Очистка ефекту
import { useEffect, useState } from "react";
export default function App() {
const [seconds, setSeconds] = useState(0);
useEffect(function() {
const timerId = setInterval(function() {
setSeconds(function(prevSeconds) {
return prevSeconds + 1;
});
}, 1000);
return function() {
clearInterval(timerId);
};
}, []);
return <p>Seconds: {seconds}</p>;
}
Функція, яку повертає useEffect, виконується під час очищення: перед демонтуванням компонента або перед повторним запуском ефекту.
Запит до API через useEffect
import { useEffect, useState } from "react";
export default function App() {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
useEffect(function() {
async function loadPosts() {
try {
setIsLoading(true);
setError("");
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5"
);
if (!response.ok) {
throw new Error("Failed to load posts");
}
const data = await response.json();
setPosts(data);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
}
loadPosts();
}, []);
if (isLoading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error: {error}</p>;
}
return (
<ul>
{posts.map(function(post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
);
}
Типові помилки з useEffect
- Забувають масив залежностей
- Кладуть у залежності нестабільні значення і отримують зайві перезапуски
- Роблять нескінченний цикл оновлень
- Забувають cleanup для таймерів, підписок або event listeners
Приклад нескінченного циклу
// Неправильно
useEffect(function() {
setCount(count + 1);
});
Без масиву залежностей ефект запускається після кожного рендеру. Якщо він ще й оновлює state — компонент може зайти в нескінченний цикл.
7. useRef — посилання на DOM і збереження значення без ререндеру
useRef часто використовують у двох випадках: щоб отримати доступ до DOM-елемента або щоб зберегти значення між рендерами без перерисовки.
Фокус на input через useRef
import { useRef } from "react";
export default function App() {
const inputRef = useRef(null);
function handleFocus() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>Focus input</button>
</div>
);
}
Збереження попереднього значення
import { useEffect, useRef, useState } from "react";
export default function App() {
const [value, setValue] = useState("");
const previousValueRef = useRef("");
useEffect(function() {
previousValueRef.current = value;
}, [value]);
return (
<div>
<input
type="text"
value={value}
onChange={function(event) {
setValue(event.target.value);
}}
/>
<p>Current: {value}</p>
<p>Previous: {previousValueRef.current}</p>
</div>
);
}
8. useMemo — мемоізація обчислень
useMemo використовується, коли якесь обчислення дороге і не повинно перераховуватись на кожному рендері без потреби.
Базовий приклад useMemo
import { useMemo, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
const doubledCount = useMemo(function() {
return count * 2;
}, [count]);
return (
<div>
<input
type="text"
value={text}
onChange={function(event) {
setText(event.target.value);
}}
/>
<p>Count: {count}</p>
<p>Doubled: {doubledCount}</p>
<button
onClick={function() {
setCount(function(prevCount) {
return prevCount + 1;
});
}}
>
Increment
</button>
</div>
);
}
У цьому прикладі значення doubledCount перераховується лише тоді, коли змінюється count.
Коли useMemo не потрібен
Якщо обчислення просте, наприклад count * 2, useMemo зазвичай не потрібен. Його варто використовувати там, де справді є вигода: складні фільтрації, сортування, великі списки, дорогі обчислення.
9. useCallback — мемоізація функцій
useCallback зберігає одну й ту саму функцію між рендерами, поки не зміняться залежності. Зазвичай він потрібен разом з React.memo або коли функція входить у залежності іншого hook.
Базовий приклад useCallback
import { useCallback, useState } from "react";
function Child({ onClick }) {
return <button onClick={onClick}>Child button</button>;
}
export default function App() {
const [count, setCount] = useState(0);
const handleClick = useCallback(function() {
console.log("Clicked");
}, []);
return (
<div>
<p>Count: {count}</p>
<button
onClick={function() {
setCount(function(prevCount) {
return prevCount + 1;
});
}}
>
Increment parent
</button>
<Child onClick={handleClick} />
</div>
);
}
useCallback + React.memo
import { memo, useCallback, useState } from "react";
const Child = memo(function Child({ onDelete }) {
console.log("Child rendered");
return <button onClick={onDelete}>Delete</button>;
});
export default function App() {
const [count, setCount] = useState(0);
const handleDelete = useCallback(function() {
alert("Deleted");
}, []);
return (
<div>
<p>Count: {count}</p>
<button
onClick={function() {
setCount(function(prevCount) {
return prevCount + 1;
});
}}
>
Update parent
</button>
<Child onDelete={handleDelete} />
</div>
);
}
Якщо без useCallback передавати нову функцію на кожному рендері, навіть memo-компонент може ререндеритись.
Коли useCallback не потрібен
Не треба обгортати кожну функцію в useCallback “на всяк випадок”. Це не магічне прискорення. Використовуй його тоді, коли є конкретна причина: оптимізація дочірнього memo-компонента або стабільність функції в залежностях.
10. Custom Hooks — власні hooks
Якщо якась логіка повторюється в кількох компонентах, її можна винести у власний hook. Назва custom hook теж повинна починатись з use.
Приклад custom hook
import { useEffect, useState } from "react";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(function() {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return function() {
window.removeEventListener("resize", handleResize);
};
}, []);
return width;
}
export default function App() {
const width = useWindowWidth();
return <p>Window width: {width}px</p>;
}
У цьому прикладі логіка відстеження ширини вікна винесена у власний hook.
Коли custom hook особливо корисний
- Коли однакова логіка повторюється в кількох компонентах
- Коли треба сховати складну роботу з useEffect, useRef або useState
- Коли хочеш зробити компонент чистішим і читабельнішим
11. Повний приклад: Props + Events + Keys + Hooks разом
import { useState } from "react";
function TodoForm({ onAddTodo }) {
const [text, setText] = useState("");
function handleSubmit(event) {
event.preventDefault();
const trimmedText = text.trim();
if (!trimmedText) {
return;
}
onAddTodo(trimmedText);
setText("");
}
function handleChange(event) {
setText(event.target.value);
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={handleChange}
placeholder="Enter todo"
/>
<button type="submit">Add</button>
</form>
);
}
function TodoList({ todos, onRemoveTodo }) {
return (
<ul>
{todos.map(function(todo) {
return (
<li key={todo.id}>
<span>{todo.text}</span>
<button
onClick={function() {
onRemoveTodo(todo.id);
}}
>
Delete
</button>
</li>
);
})}
</ul>
);
}
export default function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn props" },
{ id: 2, text: "Learn events" },
]);
function handleAddTodo(text) {
setTodos(function(prevTodos) {
return [
...prevTodos,
{
id: Date.now(),
text: text,
},
];
});
}
function handleRemoveTodo(id) {
setTodos(function(prevTodos) {
return prevTodos.filter(function(todo) {
return todo.id !== id;
});
});
}
return (
<div>
<h1>Todo App</h1>
<TodoForm onAddTodo={handleAddTodo} />
<TodoList todos={todos} onRemoveTodo={handleRemoveTodo} />
</div>
);
}
У цьому прикладі:
- useState зберігає список задач
- Props передають дані і функції між компонентами
- Events обробляють submit форми, change input та click по кнопці
- Keys використовуються для стабільного рендеру списку
12. Vite: як використовувати Props, Events, Keys, Hooks
У Vite це звичайний клієнтський React. Усі базові теми — props, events, keys, hooks — працюють прямо з коробки без особливих додаткових правил.
Створення React-проєкту через Vite
npm create vite@latest my-react-app
cd my-react-app
npm install
npm run dev
Вибір шаблону
Під час створення через Vite обери:
- Framework: React
- Variant: JavaScript або TypeScript
Типова структура Vite-проєкту
my-react-app/
public/
src/
components/
App.jsx
main.jsx
package.json
vite.config.js
Де зазвичай лежать компоненти
- src/components/Button.jsx
- src/components/UserCard.jsx
- src/components/TodoList.jsx
- src/hooks/useWindowWidth.js
Приклад App.jsx для Vite
import Counter from "./components/Counter";
export default function App() {
return (
<main>
<h1>React with Vite</h1>
<Counter />
</main>
);
}
Приклад компонента Counter.jsx
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(function(prevCount) {
return prevCount + 1;
});
}
return (
<section>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</section>
);
}
Для Vite на цьому все дуже просто: всі hooks працюють у будь-якому компоненті React.
13. Next.js: як використовувати Props, Events, Keys, Hooks
У Next.js треба бути уважнішим, бо там є розділення на Server Components і Client Components (особливо в App Router).
Це головна різниця між Vite і Next.js у контексті hooks та events.
Створення Next.js-проєкту
npx create-next-app@latest my-next-app
cd my-next-app
npm run dev
Типова структура Next.js (App Router)
my-next-app/
app/
page.js
layout.js
components/
Counter.jsx
UserList.jsx
public/
package.json
Головна різниця між Vite і Next.js
- У Vite всі React-компоненти по суті клієнтські
- У Next.js App Router компоненти за замовчуванням серверні
- Для useState, useEffect, useRef та обробки подій у Next.js треба client component
- Для цього на початку файлу пишуть "use client";
Server Component у Next.js
export default function Page() {
const users = [
{ id: 1, name: "Anna" },
{ id: 2, name: "Ivan" },
];
return (
<ul>
{users.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Тут можна використовувати JSX, props, map і key. Але не можна використовувати client-side hooks на кшталт useState або onClick без "use client".
Client Component у Next.js
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(function(prevCount) {
return prevCount + 1;
});
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
Якщо у компоненті є useState, useEffect, useRef, onClick, onChange або інші браузерні інтеракції — це має бути client component.
Передача props від Server Component до Client Component
// app/page.js
import UserList from "@/components/UserList";
export default function Page() {
const users = [
{ id: 1, name: "Anna" },
{ id: 2, name: "Ivan" },
];
return <UserList users={users} />;
}
// components/UserList.jsx
"use client";
export default function UserList({ users }) {
return (
<ul>
{users.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Так можна: серверний компонент передає звичайні серіалізовані дані через props у клієнтський компонент.
Що важливо пам’ятати про props у Next.js
Якщо серверний компонент передає props у клієнтський, ці props повинні бути серіалізованими. Простими словами: рядки, числа, boolean, масиви, об’єкти.
Передавати довільні функції із server component у client component не можна так само вільно, як у звичайному React на Vite.
Де створювати інтерактивні компоненти у Next.js
- components/Counter.jsx
- components/TodoForm.jsx
- components/SearchInput.jsx
- components/ThemeSwitcher.jsx
Якщо компонент має state, обробники подій або hooks — швидше за все це client component.
useEffect у Next.js
"use client";
import { useEffect, useState } from "react";
export default function Clock() {
const [time, setTime] = useState("");
useEffect(function() {
setTime(new Date().toLocaleTimeString());
}, []);
return <p>Time: {time}</p>;
}
useEffect працює лише у client component.
Коли різниці між Vite і Next.js майже немає
Для самих понять props, events, keys і базових hooks логіка однакова. Відрізняється переважно оточення:
- У Vite все клієнтське
- У Next.js App Router треба явно вказувати "use client" для інтерактивних компонентів
14. Порівняння: Vite vs Next.js по цій темі
Props
- У Vite працюють як у звичайному React
- У Next.js теж працюють так само, але між server/client component є обмеження на несеріалізовані дані
Events
- У Vite працюють у будь-якому компоненті
- У Next.js події працюють лише в client component
Keys
- У Vite і Next.js правила однакові
- Потрібен стабільний унікальний key
Hooks
- У Vite hooks доступні в будь-якому React-компоненті
- У Next.js client-side hooks доступні лише після "use client"
15. Часті помилки початківців
- Передають props, але намагаються змінювати їх всередині компонента
- Викликають функцію прямо в onClick замість передачі посилання
- Забувають event.preventDefault() у формах
- Використовують index як key у динамічних списках
- Використовують Math.random() для key
- Викликають hooks усередині if
- Забувають cleanup у useEffect
- У Next.js намагаються використати useState без "use client"
16. Практична схема мислення при написанні компонента
- Подумай, які дані компонент повинен отримати ззовні — це props
- Подумай, чи є в нього власний змінний стан — це useState
- Подумай, чи має він реагувати на дії користувача — це events
- Подумай, чи рендерить він список — тоді потрібен key
- Подумай, чи є побічні дії — тоді useEffect
- Подумай, чи є доступ до DOM — тоді useRef
17. Міні-шпаргалка
Props
function Card({ title }) {
return <h2>{title}</h2>;
}
<Card title="Hello" />
Event
<button onClick={handleClick}>Click</button>
Key
{items.map(function(item) {
return <li key={item.id}>{item.name}</li>;
})}
useState
const [count, setCount] = useState(0);
useEffect
useEffect(function() {
console.log("Mounted");
}, []);
useRef
const inputRef = useRef(null);
Next.js client component
"use client";
18. Висновок
Якщо коротко, то:
Props передають дані в компонент.
Events дозволяють реагувати на дії користувача.
Keys допомагають React правильно оновлювати списки.
Hooks додають стан, ефекти, refs та іншу логіку у функціональні компоненти.
У Vite все це працює як у звичайному React.
У Next.js треба окремо пам’ятати про "use client" для інтерактивних компонентів.
Якщо добре засвоїти ці чотири теми, то далі набагато легше буде вивчати форми, роутинг, глобальний стан, API-запити, оптимізацію та складніші патерни React.
Завдання: React Props (легкий рівень)
Завдання 1 — Базові props
Створи компонент User, який приймає prop
name.
- передай імʼя з батьківського компонента
- відобрази його всередині
<h2>
Завдання 2 — Деструктуризація
Створи компонент Product, який приймає props
title та price.
- використай деструктуризацію в параметрах функції
- відобрази назву товару та його ціну
Завдання: React Events
Завдання 1
Створи кнопку з обробником onClick.
- виведи повідомлення в консоль
Завдання 2
Створи input з подією onChange.
- зберігай введений текст у state
- відображай його під input
Завдання 3
Оброби подію onSubmit у формі.
- заборони стандартну поведінку
- виведи дані форми в консоль
Завдання 4
Передай параметр у функцію-обробник кліку.
Завдання 5
Оброби onMouseEnter та змінюй колір елемента.
Завдання 6
Реалізуй кнопку, яка перемикає текст між двома значеннями.
Завдання 7
Оброби подію клавіатури onKeyDown.
Завдання 8
Передай обробник події через props у дочірній компонент.
Завдання 9
Створи кнопку видалення елемента зі списку.
Завдання 10
Поясни різницю між onClick={handleClick} та
onClick={() => handleClick()}.
Завдання: React Keys
Завдання 1
Відобрази масив обʼєктів списком без key.
Завдання 2
Додай унікальний key використовуючи
id.
Завдання 3
Використай index як key.
- поясни, у яких випадках це допустимо
Завдання 4
Додай можливість видалення елемента зі списку.
- перевір, чи коректно працює key
Завдання 5
Зміни порядок елементів у списку.
- поясни, як React використовує key під час ререндеру
Завдання: React Hooks (від базового до junior+)
Блок 1 — База (1–10)
Завдання 1. Створи лічильник з
useState.
Завдання 2. Додай кнопку зменшення значення.
Завдання 3. Додай кнопку скидання до 0.
Завдання 4. Використай два useState в
одному компоненті.
Завдання 5. Зроби toggle (true / false).
Завдання 6. Зміни state на основі попереднього значення.
Завдання 7. Використай useEffect без
масиву залежностей.
Завдання 8. Додай порожній масив залежностей.
Завдання 9. Додай залежність у
useEffect.
Завдання 10. Реалізуй cleanup-функцію.
Блок 2 — Середній рівень (11–20)
Завдання 11. Зроби таймер з
setInterval і cleanup.
Завдання 12. Реалізуй лічильник кліків з логуванням у useEffect.
Завдання 13. Зроби запит до API в useEffect.
Завдання 14. Додай індикатор завантаження.
Завдання 15. Оброби помилку запиту.
Завдання 16. Використай кілька useEffect в одному компоненті.
Завдання 17. Створи простий reducer через
useReducer.
Завдання 18. Реалізуй форму з кількома полями через useState.
Завдання 19. Перепиши форму на useReducer.
Завдання 20. Забезпеч правильні залежності у useEffect.
Блок 3 — Junior+ (21–30)
Завдання 21. Створи кастомний hook.
Завдання 22. Винеси логіку запиту в кастомний hook.
Завдання 23. Реалізуй debounce через useEffect.
Завдання 24. Реалізуй збереження в localStorage.
Завдання 25. Оптимізуй компонент через
useCallback.
Завдання 26. Оптимізуй обчислення через
useMemo.
Завдання 27. Реалізуй контрольований та неконтрольований input.
Завдання 28. Досліди проблему зайвих ререндерів.
Завдання 29. Реалізуй складний reducer з кількома action.
Завдання 30. Поясни правила hooks та типові помилки.
Form validation with Zod and React Hook Form
Form validation with Zod and React Hook Form — це популярний підхід для перевірки форм у React-застосунках.
React Hook Form відповідає за керування станом форми, реєстрацію полів, сабміт, помилки та оптимізацію ререндерів.
Zod відповідає за опис правил валідації у вигляді схеми: які поля потрібні, які типи даних очікуються, яка мінімальна довжина, чи збігаються паролі, чи коректний email тощо.
Разом вони дають зручну, масштабовану і зрозумілу систему валідації, яку легко підтримувати в реальних проєктах.
Що треба розуміти на trainee рівні
- Що таке форма у React
- Що таке controlled та uncontrolled input
- Для чого потрібен React Hook Form
- Для чого потрібен Zod
- Що таке schema validation
- Як підключити zodResolver
- Як показувати помилки користувачу
- Як перевіряти текстові поля, пароль, email, checkbox, select
- Як робити кастомні перевірки
- Як типізувати форму через z.infer у TypeScript
- Яка різниця між Vite і Next.js у контексті форм
Що робить React Hook Form
- Реєструє поля форми через register
- Збирає значення полів
- Відстежує помилки
- Керує submit-логікою через handleSubmit
- Дає доступ до formState: errors, isSubmitting, isValid та інших станів
- Дає методи reset, setValue, watch, trigger, getValues
- Працює швидко і з мінімумом зайвих ререндерів
Що робить Zod
- Описує форму як схему
- Перевіряє типи і значення
- Дає зрозумілі повідомлення про помилки
- Підходить і для фронтенду, і для бекенду
- Добре поєднується з TypeScript через автоматичне виведення типів
Базова зв'язка бібліотек
React Hook Form + Zod + @hookform/resolvers
Тут React Hook Form керує самою формою, а Zod перевіряє
дані. Бібліотека
@hookform/resolvers потрібна як міст між ними.
Встановлення для Vite React
npm create vite@latest my-app
cd my-app
npm install
npm install react-hook-form zod @hookform/resolvers
Встановлення для Next.js
npx create-next-app@latest my-app
cd my-app
npm install react-hook-form zod @hookform/resolvers
Мінімальна структура у Vite
src/
App.jsx
components/
LoginForm.jsx
schemas/
authSchema.js
Мінімальна структура у Next.js App Router
app/
page.jsx
components/
LoginForm.jsx
schemas/
authSchema.js
Мінімальна структура у Next.js Pages Router
pages/
index.jsx
components/
LoginForm.jsx
schemas/
authSchema.js
Головна різниця між Vite і Next.js
У Vite звичайні React-компоненти одразу працюють як клієнтські.
У Next.js App Router інтерактивні компоненти з формами
потрібно робити client component, тобто додавати на початку
файлу директиву
"use client";.
У Next.js Pages Router цього окремо робити не треба, бо там сторінки і так працюють як клієнтські React-компоненти у звичному стилі.
Перша схема Zod
// schemas/authSchema.js
import { z } from "zod";
export const loginSchema = z.object({
email: z.email("Введи коректний email"),
password: z
.string()
.min(6, "Пароль повинен містити мінімум 6 символів"),
});
Тут ми описали два поля:
email— повинен бути коректним emailpassword— рядок мінімум з 6 символів
Простий компонент форми
// components/LoginForm.jsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema } from "../schemas/authSchema";
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async function(data) {
console.log("Форма валідна, дані:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
placeholder="Enter email"
{...register("email")}
/>
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
placeholder="Enter password"
{...register("password")}
/>
{errors.password && <p>{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Login"}
</button>
</form>
);
}
Пояснення до цього коду
useForm()створює логіку форми-
resolver: zodResolver(loginSchema)підключає схему Zod -
register("email")реєструє поле у формі -
handleSubmit(onSubmit)запускає валідацію перед сабмітом errorsмістить помилки по полях-
isSubmittingзручно використовувати для блокування кнопки -
noValidateвимикає стандартну браузерну валідацію, щоб не змішувати її з Zod
Підключення у Vite
// src/App.jsx
import LoginForm from "./components/LoginForm";
export default function App() {
return (
<main>
<h1>Zod + React Hook Form</h1>
<LoginForm />
</main>
);
}
Підключення у Next.js App Router
// components/LoginForm.jsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema } from "../schemas/authSchema";
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
});
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("email")} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Submit</button>
</form>
);
}
// app/page.jsx
import LoginForm from "../components/LoginForm";
export default function HomePage() {
return (
<main>
<h1>Next.js App Router</h1>
<LoginForm />
</main>
);
}
Підключення у Next.js Pages Router
// pages/index.jsx
import LoginForm from "../components/LoginForm";
export default function HomePage() {
return (
<main>
<h1>Next.js Pages Router</h1>
<LoginForm />
</main>
);
}
Найчастіше використовувані методи Zod для рядків
import { z } from "zod";
const schema = z.object({
username: z
.string()
.min(3, "Мінімум 3 символи")
.max(20, "Максимум 20 символів"),
email: z.email("Некоректний email"),
password: z
.string()
.min(8, "Мінімум 8 символів")
.regex(/[A-Z]/, "Додай хоча б одну велику літеру")
.regex(/[0-9]/, "Додай хоча б одну цифру"),
});
Валідація числа
import { z } from "zod";
const profileSchema = z.object({
age: z
.number({
error: "Вік повинен бути числом",
})
.min(18, "Мінімальний вік 18"),
});
Але з HTML input значення зазвичай приходить як рядок. Тому на практиці зручніше або перетворювати значення через React Hook Form, або використовувати coercion у Zod.
Числа через z.coerce.number()
import { z } from "zod";
const profileSchema = z.object({
age: z.coerce.number().min(18, "Мінімальний вік 18"),
});
Це дуже зручно для input type="number", бо значення з форми буде автоматично приведене до числа.
Приклад форми з number input
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const profileSchema = z.object({
name: z.string().min(2, "Мінімум 2 символи"),
age: z.coerce.number().min(18, "Має бути 18 або більше"),
});
export default function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(profileSchema),
defaultValues: {
name: "",
age: "",
},
});
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("name")} placeholder="Name" />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("age")} type="number" placeholder="Age" />
{errors.age && <p>{errors.age.message}</p>}
<button type="submit">Save</button>
</form>
);
}
Валідація checkbox
import { z } from "zod";
const termsSchema = z.object({
terms: z.literal(true, {
error: "Потрібно погодитися з умовами",
}),
});
Це хороший спосіб перевірити, що користувач точно поставив галочку.
Валідація select
import { z } from "zod";
const settingsSchema = z.object({
role: z
.string()
.min(1, "Оберіть роль"),
});
<select {...register("role")}>
<option value="">Choose role</option>
<option value="student">Student</option>
<option value="mentor">Mentor</option>
</select>
Валідація пароля і confirm password
import { z } from "zod";
export const registerSchema = z
.object({
name: z.string().min(2, "Мінімум 2 символи"),
email: z.email("Введи коректний email"),
password: z
.string()
.min(8, "Мінімум 8 символів"),
confirmPassword: z
.string()
.min(8, "Мінімум 8 символів"),
})
.refine(function(data) {
return data.password === data.confirmPassword;
}, {
message: "Паролі не збігаються",
path: ["confirmPassword"],
});
Метод refine корисний, коли треба перевіряти
зв'язок між кількома полями.
Форма реєстрації з confirm password
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registerSchema } from "../schemas/registerSchema";
export default function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
});
function onSubmit(data) {
console.log("Успішна реєстрація:", data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("name")} placeholder="Name" />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<input
{...register("confirmPassword")}
type="password"
placeholder="Confirm password"
/>
{errors.confirmPassword && (
<p>{errors.confirmPassword.message}</p>
)}
<button type="submit">Register</button>
</form>
);
}
Кастомна перевірка через refine
import { z } from "zod";
const usernameSchema = z.object({
username: z
.string()
.min(3, "Мінімум 3 символи")
.refine(function(value) {
return !value.includes("admin");
}, "Слово admin використовувати не можна"),
});
Кастомна перевірка через superRefine
import { z } from "zod";
const bookingSchema = z
.object({
startDate: z.string().min(1, "Оберіть дату початку"),
endDate: z.string().min(1, "Оберіть дату завершення"),
})
.superRefine(function(data, ctx) {
if (data.startDate && data.endDate && data.endDate < data.startDate) {
ctx.addIssue({
code: "custom",
path: ["endDate"],
message: "Дата завершення не може бути раніше дати початку",
});
}
});
superRefine зручний, коли треба додати більш
складну логіку і явно вказати, в яке саме поле записати
помилку.
Корисні параметри useForm
const form = useForm({
resolver: zodResolver(schema),
mode: "onSubmit",
reValidateMode: "onChange",
defaultValues: {
email: "",
password: "",
},
});
-
mode: "onSubmit"— валідація запускається при сабміті -
mode: "onBlur"— валідація запускається після втрати фокуса -
mode: "onChange"— валідація запускається при кожній зміні -
defaultValues— стартові значення форми
Коли який mode краще використовувати
-
onSubmit— найпростіший і найчастіший варіант для початку -
onBlur— зручний, коли треба не лякати користувача помилками під час набору -
onChange— підходить для миттєвого зворотного зв'язку, але може бути шумним -
all— запускає перевірки і на blur, і на change
Приклад з mode: onBlur
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
mode: "onBlur",
});
Як працює errors
{errors.email && <p>{errors.email.message}</p>}
Якщо поле email не пройшло валідацію, то в
errors.email з'явиться об'єкт з
повідомленням.
Доступ до кількох станів formState
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isDirty, isValid, isSubmitting, touchedFields },
} = useForm({
resolver: zodResolver(schema),
mode: "onChange",
});
isDirty— чи змінювали формуisValid— чи валідна формаisSubmitting— чи йде submittouchedFields— які поля вже торкались
Блокування кнопки поки форма невалідна
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm({
resolver: zodResolver(schema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit" disabled={!isValid}>
Submit
</button>
</form>
);
watch — відстеження значень у реальному часі
const { register, watch } = useForm({
defaultValues: {
email: "",
},
});
const emailValue = watch("email");
return (
<>
<input {...register("email")} />
<p>Поточний email: {emailValue}</p>
</>
);
Це зручно для прев'ю, динамічних підказок або умовного рендеру.
reset — очищення форми після успішного сабміту
const {
register,
handleSubmit,
reset,
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
email: "",
password: "",
},
});
function onSubmit(data) {
console.log(data);
reset();
}
Асинхронний submit
async function onSubmit(data) {
try {
console.log("Відправка на сервер:", data);
await new Promise(function(resolve) {
setTimeout(resolve, 1000);
});
console.log("Успіх");
} catch (error) {
console.error(error);
}
}
Відправка форми через fetch
async function onSubmit(data) {
try {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Помилка відправки форми");
}
const result = await response.json();
console.log(result);
} catch (error) {
console.error(error);
}
}
Серверні помилки через setError
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data) {
try {
const response = await fakeLoginRequest(data);
if (!response.success) {
setError("root.serverError", {
type: "server",
message: "Неправильний email або пароль",
});
return;
}
console.log("Успіх");
} catch (error) {
setError("root.serverError", {
type: "server",
message: "Сервер тимчасово недоступний",
});
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p>{errors.password.message}</p>}
{errors.root?.serverError && (
<p>{errors.root.serverError.message}</p>
)}
<button type="submit">Login</button>
</form>
);
Коли потрібен Controller
Більшість звичайних input можна підключати через
register.
Але якщо ти працюєш зі сторонніми UI-бібліотеками або
складними controlled компонентами, часто потрібен
Controller.
Приклад Controller
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export default function ControlledForm() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
firstName: "",
},
});
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<Controller
name="firstName"
control={control}
render={function({ field }) {
return (
<input
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
placeholder="First name"
/>
);
}}
/>
{errors.firstName && <p>{errors.firstName.message}</p>}
<button type="submit">Save</button>
</form>
);
}
TypeScript + Zod + React Hook Form
// schemas/loginSchema.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.email("Invalid email"),
password: z.string().min(6, "Minimum 6 characters"),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// components/LoginForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema, type LoginFormData } from "../schemas/loginSchema";
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
function onSubmit(data: LoginFormData) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("email")} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Submit</button>
</form>
);
}
Через z.infer тип форми автоматично виводиться
зі схеми. Це дуже зручно: одна схема і для валідації, і для
типів.
Корисний патерн: схема окремо, форма окремо
src/
schemas/
loginSchema.ts
registerSchema.ts
components/
forms/
LoginForm.tsx
RegisterForm.tsx
Так код легше підтримувати. Схеми не змішуються з UI.
Приклад великої схеми профілю
import { z } from "zod";
export const profileSchema = z.object({
firstName: z.string().min(2, "Мінімум 2 символи"),
lastName: z.string().min(2, "Мінімум 2 символи"),
email: z.email("Некоректний email"),
age: z.coerce.number().min(18, "Мінімум 18 років"),
bio: z.string().max(300, "Максимум 300 символів").optional(),
country: z.string().min(1, "Оберіть країну"),
terms: z.literal(true, {
error: "Потрібно погодитися з умовами",
}),
});
optional, nullable, default — що означають
import { z } from "zod";
const schema = z.object({
middleName: z.string().optional(),
avatar: z.string().nullable(),
role: z.string().default("student"),
});
optional()— поле може бути відсутнім-
nullable()— поле може бутиnull -
default()— можна задати значення за замовчуванням
Перетворення даних через transform
import { z } from "zod";
const schema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.email("Некоректний email"),
});
Це зручно, коли треба автоматично обрізати пробіли або привести email до нижнього регістру.
Приклад з radio button
import { z } from "zod";
const genderSchema = z.object({
gender: z.string().min(1, "Оберіть варіант"),
});
<label>
<input type="radio" value="male" {...register("gender")} />
Male
</label>
<label>
<input type="radio" value="female" {...register("gender")} />
Female
</label>
{errors.gender && <p>{errors.gender.message}</p>}
Приклад з textarea
const schema = z.object({
message: z
.string()
.min(10, "Мінімум 10 символів")
.max(500, "Максимум 500 символів"),
});
<textarea {...register("message")} placeholder="Your message" />
{errors.message && <p>{errors.message.message}</p>}
Власний компонент поля
// components/FormField.jsx
export default function FormField(props) {
const { id, label, error, ...rest } = props;
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} {...rest} />
{error && <p>{error}</p>}
</div>
);
}
// components/LoginForm.jsx
import FormField from "./FormField";
<FormField
id="email"
label="Email"
placeholder="Enter email"
error={errors.email?.message}
{...register("email")}
/>
Такий підхід допомагає не дублювати розмітку для label, input і error.
Валідація масиву
import { z } from "zod";
const hobbiesSchema = z.object({
hobbies: z
.array(z.string())
.min(1, "Оберіть хоча б одне хобі"),
});
useFieldArray — динамічні поля
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
phones: z.array(
z.object({
value: z.string().min(10, "Мінімум 10 символів"),
})
),
});
export default function PhonesForm() {
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
phones: [{ value: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "phones",
});
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{fields.map(function(field, index) {
return (
<div key={field.id}>
<input
{...register(`phones.${index}.value`)}
placeholder="Phone number"
/>
{errors.phones?.[index]?.value && (
<p>{errors.phones[index].value.message}</p>
)}
<button type="button" onClick={function() { remove(index); }}>
Remove
</button>
</div>
);
})}
<button
type="button"
onClick={function() {
append({ value: "" });
}}
>
Add phone
</button>
<button type="submit">Save</button>
</form>
);
}
Практичний приклад: login form для trainee
// schemas/loginSchema.js
import { z } from "zod";
export const loginSchema = z.object({
email: z
.string()
.trim()
.min(1, "Email обов'язковий")
.email("Некоректний email"),
password: z
.string()
.min(1, "Пароль обов'язковий")
.min(6, "Мінімум 6 символів"),
});
// components/LoginForm.jsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema } from "../schemas/loginSchema";
export default function LoginForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(loginSchema),
mode: "onBlur",
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(data) {
try {
console.log("Дані форми:", data);
await new Promise(function(resolve) {
setTimeout(resolve, 1000);
});
reset();
} catch (error) {
console.error(error);
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
placeholder="Enter your email"
{...register("email")}
/>
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
placeholder="Enter your password"
{...register("password")}
/>
{errors.password && <p>{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Loading..." : "Log In"}
</button>
</form>
);
}
Як організувати код у реальному проєкті
- Схеми тримай у папці
schemas -
Компоненти форм тримай у
components/forms -
API-запити винось у
servicesабоapi - Повідомлення про помилки роби зрозумілими для користувача
- Не змішуй правила валідації прямо всередині JSX, якщо схема росте
Типові помилки початківців
- Забули встановити
@hookform/resolvers -
Забули підключити
resolver: zodResolver(schema) -
Забули
"use client"у Next.js App Router - Змішують HTML browser validation і Zod validation без потреби
- Не задають
defaultValues - Пишуть схему прямо всередині компонента, хоча вона велика
-
Не показують
errors.field?.messageу UI -
Для складних controlled компонентів намагаються
використати тільки
registerзамістьController
Що краще запам'ятати в першу чергу
- Створюєш схему через Zod
- Підключаєш її через
zodResolver - Реєструєш поля через
register - Показуєш помилки через
errors - Обробляєш submit через
handleSubmit -
Для Next.js App Router не забуваєш
"use client"
Коротка шпаргалка по API
useForm({
resolver: zodResolver(schema),
defaultValues: {},
mode: "onSubmit",
});
register("fieldName");
handleSubmit(onSubmit);
reset();
watch("fieldName");
setError("fieldName", { message: "Custom error" });
formState.errors
formState.isValid
formState.isSubmitting
Коротка шпаргалка по Zod
z.object({
name: z.string().min(2),
email: z.email(),
age: z.coerce.number().min(18),
terms: z.literal(true),
});
.refine(...)
.superRefine(...)
.optional()
.nullable()
.default(...)
.transform(...)
Підсумок
React Hook Form — це інструмент для керування формою.
Zod — це інструмент для опису і перевірки даних.
Resolver з'єднує їх між собою.
У Vite використання майже прямолінійне.
У Next.js App Router треба пам'ятати про
"use client"
для інтерактивних форм.
Для trainee рівня найважливіше навчитися впевнено робити прості login, register, profile і contact-форми, а вже потім переходити до динамічних полів, Controller і складних кастомних перевірок.
Практичне завдання — Реєстраційна форма (React Hook Form + Zod)
Реалізуй форму реєстрації користувача з використанням React Hook Form та Zod.
Мета завдання
Познайомитися з базовою інтеграцією Zod та React Hook Form, навчитися створювати schema, підключати resolver та відображати помилки в UI.
Функціональні вимоги
-
Створити форму з такими полями:
- Імʼя
- Пароль
- Підтвердження пароля
- Вік
-
Створити Zod schema для валідації:
- Імʼя — мінімум 2 символи
- Email — валідний формат
- Пароль — мінімум 6 символів
- Паролі повинні співпадати
- Вік — число, мінімум 18
-
Підключити
zodResolverдоuseForm - Відобразити повідомлення про помилки під кожним полем
- При успішній відправці показати блок з введеними даними (без використання console.log)
UI
- Input для кожного поля
- Кнопка "Register"
- Блок для відображення помилок
- Блок з результатом після submit
DOM-результат
- Помилки зʼявляються під конкретним полем
- При виправленні помилки повідомлення зникає
- Після успішної валідації показується введена інформація
❗ Заборонено використовувати console.log для
перевірки — тільки відображення в інтерфейсі
💡 Підказка: для перевірки співпадіння паролів використай метод refine() у Zod
💡 Підказка: вік потрібно перетворювати в number, інакше Zod буде отримувати string
Додаткове завдання — Форма створення профілю з умовною валідацією
Розшир форму і додай логіку умовної валідації.
Мета завдання
Навчитися використовувати optional поля, enum та умовну перевірку через refine().
Функціональні вимоги
-
Додати поле "Роль":
- user
- admin
- Додати поле "Код доступу"
- Якщо роль = admin, поле "Код доступу" є обовʼязковим
- Якщо роль = user, поле "Код доступу" не є обовʼязковим
- Додати чекбокс "Приймаю умови"
- Заборонити відправку форми, якщо чекбокс не активований
UI
- Select для вибору ролі
- Input для коду доступу
- Checkbox для підтвердження
DOM-результат
- Помилка, якщо admin без коду доступу
- Помилка, якщо не відмічений чекбокс
- Успішна відправка тільки при валідних даних
💡 Підказка: використай z.enum() для ролі
💡 Підказка: для умовної перевірки зручно використати refine() або superRefine()
💡 Підказка: для boolean поля використай z.literal(true), щоб змусити чекбокс бути обраним
Міні-челендж — Редагування форми (defaultValues + reset)
Додай можливість редагувати існуючі дані.
Мета завдання
Закріпити роботу з defaultValues, reset та програмним керуванням формою.
Функціональні вимоги
- Додати кнопку "Load demo user"
- При натисканні форма заповнюється тестовими даними
- Додати кнопку "Reset form"
- Кнопка очищає всі поля
DOM-результат
- Форма заповнюється автоматично
- Після reset всі значення очищаються
💡 Підказка: використай reset() з передачею обʼєкта
💡 Підказка: defaultValues задаються при ініціалізації useForm
Підсумкове завдання
Обʼєднай усі частини в один невеликий компонент ProfileForm та розділи логіку на:
- Окремий файл schema
- Окремий компонент форми
- Окремий компонент для відображення результату
Це дозволить зрозуміти, як масштабувати форму в реальному проєкті.
🎯 Ціль — не зробити ідеально, а зрозуміти: як створюється schema, як підключається resolver, як відображаються помилки та як React Hook Form керує станом форми.
Storage (localStorage / sessionStorage), Web Workers, WebSocket — повний гайд для trainee
Це три дуже важливі браузерні інструменти, які часто використовуються у сучасних React-застосунках.
Storage потрібен для збереження даних у браузері, Web Workers — для винесення важких обчислень у фоновий потік, а WebSocket — для двостороннього зв'язку з сервером у реальному часі.
Для front-end розробника важливо розуміти не тільки сам API, а і те, як правильно використовувати його у звичайному React / Vite та у Next.js.
Що саме ти повинен розуміти по цій темі
- Різницю між localStorage і sessionStorage
- Які дані можна і не можна зберігати в Storage
- Як серіалізувати об'єкти через JSON.stringify і JSON.parse
- Що Storage працює тільки в браузері
- Що Web Worker не має доступу до DOM
- Як обмінюватися даними між головним потоком і Worker через postMessage
- Для чого потрібен WebSocket і чим він відрізняється від звичайного HTTP
- Як відкривати, використовувати і закривати WebSocket-з'єднання
- Як усе це використовувати у React з useEffect, useState, useRef
- Які є відмінності між Vite і Next.js
1. Storage API — localStorage і sessionStorage
Storage API — це вбудований браузерний механізм для збереження даних у форматі ключ-значення.
Він дуже зручний для простих клієнтських сценаріїв: тема сайту, токен, остання активна вкладка, фільтри, текст чернетки, налаштування користувача.
Що таке localStorage
localStorage зберігає дані без обмеження по часу. Дані залишаються навіть після перезавантаження сторінки, закриття вкладки або браузера.
Поки ти сам не видалиш ці дані через код або користувач не очистить сховище браузера, вони зберігатимуться.
Що таке sessionStorage
sessionStorage схожий на localStorage, але живе тільки в межах поточної вкладки браузера.
Після закриття вкладки ці дані зникають. Це зручно для тимчасового стану: наприклад, кроки форми, тимчасові фільтри або чернетка даних лише на поточну сесію.
Різниця між localStorage і sessionStorage
- localStorage живе довго
- sessionStorage живе до закриття вкладки
- localStorage зручний для постійних налаштувань
- sessionStorage зручний для тимчасового стану
- Обидва зберігають тільки рядки
- Обидва доступні тільки в браузері, а не на сервері
Основні методи Storage
localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme");
localStorage.removeItem("theme");
localStorage.clear();
sessionStorage.setItem("step", "2");
const step = sessionStorage.getItem("step");
sessionStorage.removeItem("step");
sessionStorage.clear();
Базовий приклад localStorage
// Зберегти значення
localStorage.setItem("username", "Viktor");
// Отримати значення
const username = localStorage.getItem("username");
console.log(username);
// Видалити одне значення
localStorage.removeItem("username");
// Очистити все сховище
localStorage.clear();
Чому важливо пам'ятати про рядки
Storage зберігає значення як рядки. Якщо ти спробуєш зберегти об'єкт напряму, отримаєш неправильний результат.
Неправильний приклад
const user = {
name: "Anna",
age: 25
};
localStorage.setItem("user", user);
const result = localStorage.getItem("user");
console.log(result);
Такий код збереже рядок на кшталт
[object Object], а не справжній об'єкт.
Правильний приклад через JSON
const user = {
name: "Anna",
age: 25
};
// Зберігаємо об'єкт як JSON-рядок
localStorage.setItem("user", JSON.stringify(user));
// Читаємо назад і перетворюємо у JS-об'єкт
const storedUser = localStorage.getItem("user");
const parsedUser = JSON.parse(storedUser);
console.log(parsedUser.name);
console.log(parsedUser.age);
Безпечне читання з try/catch
function getUserFromStorage() {
try {
const value = localStorage.getItem("user");
if (!value) {
return null;
}
return JSON.parse(value);
} catch (error) {
// Якщо JSON пошкоджений, повертаємо null
console.error("Помилка читання user зі storage:", error);
return null;
}
}
Приклад збереження теми сайту
const themeToggleButton = document.getElementById("theme-toggle");
function applyTheme(theme) {
document.body.dataset.theme = theme;
}
function initTheme() {
const savedTheme = localStorage.getItem("theme") || "light";
applyTheme(savedTheme);
}
themeToggleButton.addEventListener("click", function() {
const currentTheme = document.body.dataset.theme || "light";
const nextTheme = currentTheme === "light" ? "dark" : "light";
localStorage.setItem("theme", nextTheme);
applyTheme(nextTheme);
});
initTheme();
Приклад автозбереження тексту форми
const textarea = document.getElementById("draft-message");
textarea.value = localStorage.getItem("draft-message") || "";
textarea.addEventListener("input", function(event) {
localStorage.setItem("draft-message", event.target.value);
});
Storage event
Якщо localStorage змінюється в одній вкладці, інша вкладка
того ж сайту може зловити подію storage.
Це зручно для синхронізації вкладок, наприклад при logout.
Приклад storage event
window.addEventListener("storage", function(event) {
if (event.key === "token" && event.newValue === null) {
console.log("Токен видалено в іншій вкладці");
console.log("Тут можна зробити logout і перенаправлення");
}
});
Що не варто зберігати у Storage
- Дуже великі об'єми даних
- Секретні дані, які не повинні бути доступні з JavaScript
- Паролі
- Критично важливі дані без додаткового захисту
- Складні кеші, для яких краще підійде IndexedDB або сервер
Патерн з терміном життя для localStorage
У localStorage немає вбудованого часу життя, але його можна реалізувати вручну.
Приклад TTL для localStorage
function setItemWithExpiry(key, value, ttlInMs) {
const now = Date.now();
const item = {
value: value,
expiry: now + ttlInMs
};
localStorage.setItem(key, JSON.stringify(item));
}
function getItemWithExpiry(key) {
const itemStr = localStorage.getItem(key);
if (!itemStr) {
return null;
}
try {
const item = JSON.parse(itemStr);
const now = Date.now();
if (now > item.expiry) {
localStorage.removeItem(key);
return null;
}
return item.value;
} catch (error) {
console.error("Помилка читання item з TTL:", error);
return null;
}
}
setItemWithExpiry("promo-banner-closed", true, 60 * 60 * 1000);
const isClosed = getItemWithExpiry("promo-banner-closed");
2. Storage у React
У React Storage зазвичай використовують у двох місцях:
- Під час ініціалізації state
- У useEffect для збереження оновленого значення
React приклад: збереження теми
import { useEffect, useState } from "react";
export default function ThemeSwitcher() {
const [theme, setTheme] = useState(function() {
const savedTheme = localStorage.getItem("theme");
return savedTheme || "light";
});
useEffect(function() {
localStorage.setItem("theme", theme);
document.body.dataset.theme = theme;
}, [theme]);
function handleToggle() {
setTheme(function(currentTheme) {
return currentTheme === "light" ? "dark" : "light";
});
}
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={handleToggle}>Toggle theme</button>
</div>
);
}
Кращий варіант для SSR-безпечності
Якщо середовище може бути серверним, краще читати Storage всередині useEffect або перевіряти наявність window.
SSR-safe приклад
import { useEffect, useState } from "react";
export default function ThemeSwitcher() {
const [theme, setTheme] = useState("light");
useEffect(function() {
const savedTheme = window.localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
useEffect(function() {
window.localStorage.setItem("theme", theme);
}, [theme]);
function handleToggle() {
setTheme(function(currentTheme) {
return currentTheme === "light" ? "dark" : "light";
});
}
return (
<button onClick={handleToggle}>
Current theme: {theme}
</button>
);
}
3. Storage у Vite
У Vite немає специфічних складнощів для Storage, бо застосунок зазвичай працює як клієнтський React SPA.
Тобто localStorage і sessionStorage зазвичай доступні напряму в компонентах, якщо код виконується у браузері.
Приклад у Vite
// src/components/UserSettings.jsx
import { useEffect, useState } from "react";
export default function UserSettings() {
const [language, setLanguage] = useState("uk");
useEffect(function() {
const savedLanguage = localStorage.getItem("language");
if (savedLanguage) {
setLanguage(savedLanguage);
}
}, []);
useEffect(function() {
localStorage.setItem("language", language);
}, [language]);
function handleChange(event) {
setLanguage(event.target.value);
}
return (
<select value={language} onChange={handleChange}>
<option value="uk">Ukrainian</option>
<option value="en">English</option>
</select>
);
}
Що важливо у Vite
- Storage — це браузерний API, тому він доступний у компонентах після рендеру в браузері
- Для простих SPA-сценаріїв проблем зазвичай немає
- Все одно краще не читати localStorage без потреби прямо в глобальній області модуля
4. Storage у Next.js
Тут уже є важлива різниця. У Next.js, особливо в App Router, компоненти за замовчуванням серверні.
А localStorage і sessionStorage існують тільки в браузері. Тому використовувати їх можна лише у Client Components.
Головне правило для Next.js
- Додай
"use client";на початку файла -
Працюй зі Storage у useEffect або після перевірки
typeof window !== "undefined" - Не використовуй localStorage у Server Components
Правильний приклад для Next.js App Router
"use client";
import { useEffect, useState } from "react";
export default function ThemeClient() {
const [theme, setTheme] = useState("light");
useEffect(function() {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
useEffect(function() {
localStorage.setItem("theme", theme);
}, [theme]);
return (
<button
onClick={function() {
setTheme(function(currentTheme) {
return currentTheme === "light" ? "dark" : "light";
});
}}
>
Theme: {theme}
</button>
);
}
Неправильний приклад для Next.js
import { useState } from "react";
export default function Page() {
const [theme, setTheme] = useState(localStorage.getItem("theme"));
return <div>{theme}</div>;
}
Тут буде проблема, бо localStorage недоступний
на сервері.
Патерн через перевірку window
"use client";
import { useState } from "react";
export default function Example() {
const [value, setValue] = useState(function() {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem("my-key") || "";
});
return <div>{value}</div>;
}
5. Web Workers — що це таке
Web Worker — це окремий фоновий потік, у якому можна виконувати JavaScript-код без блокування основного UI-потоку.
Якщо ти запускаєш важкі обчислення у звичайному коді сторінки, інтерфейс може почати "фризити". Worker дозволяє цього уникнути.
Коли варто використовувати Worker
- Важкі обчислення
- Парсинг великих масивів даних
- Складні цикли і трансформації
- Робота з файлами і даними у фоні
- Коли UI починає підвисати через синхронний код
Коли Worker не потрібен
- Для простих обчислень
- Для дрібної логіки форм
- Для DOM-маніпуляцій
- Для рідкісних нетяжких операцій
Головні обмеження Worker
- Worker не має прямого доступу до DOM
- Worker не може напряму викликати document.querySelector
- Worker спілкується з головним потоком через повідомлення
- Треба явно завершувати Worker, коли він більше не потрібен
Базова схема роботи
Головний потік:
створює Worker
відправляє дані через postMessage
отримує результат через onmessage
Worker:
слухає onmessage
виконує обчислення
повертає результат через postMessage
Найпростіший приклад Worker
// worker.js
self.onmessage = function(event) {
const number = event.data;
const result = number * 2;
self.postMessage(result);
};
Код головного потоку
// main.js
const worker = new Worker("./worker.js");
worker.postMessage(5);
worker.onmessage = function(event) {
console.log("Результат від worker:", event.data);
};
Приклад важкого обчислення
// prime-worker.js
function countPrimes(limit) {
let count = 0;
for (let number = 2; number <= limit; number += 1) {
let isPrime = true;
for (let divisor = 2; divisor * divisor <= number; divisor += 1) {
if (number % divisor === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
count += 1;
}
}
return count;
}
self.onmessage = function(event) {
const limit = event.data;
const totalPrimes = countPrimes(limit);
self.postMessage(totalPrimes);
};
Головний потік для prime-worker
const worker = new Worker("./prime-worker.js");
worker.postMessage(100000);
worker.onmessage = function(event) {
console.log("Кількість простих чисел:", event.data);
};
worker.onerror = function(error) {
console.error("Помилка у worker:", error);
};
Важливі методи і події Worker
new Worker()— створити worker-
worker.postMessage()— відправити дані worker-у worker.onmessage— отримати відповідьworker.onerror— обробити помилкуworker.terminate()— завершити worker-
self.onmessage— обробити повідомлення всередині worker -
self.postMessage()— відправити відповідь з worker
React приклад з Worker
import { useEffect, useRef, useState } from "react";
export default function PrimeCounter() {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(function() {
workerRef.current = new Worker(new URL("./prime-worker.js", import.meta.url));
workerRef.current.onmessage = function(event) {
setResult(event.data);
setIsLoading(false);
};
workerRef.current.onerror = function(error) {
console.error("Помилка worker:", error);
setIsLoading(false);
};
return function() {
// Завершуємо worker при демонтажі компонента
workerRef.current.terminate();
};
}, []);
function handleStart() {
setIsLoading(true);
workerRef.current.postMessage(100000);
}
return (
<div>
<button onClick={handleStart}>Count primes</button>
{isLoading && <p>Calculating...</p>}
{result !== null && <p>Result: {result}</p>}
</div>
);
}
6. Web Workers у Vite
У Vite робота з worker дуже зручна. Один із найпоширеніших
способів — імпорт через ?worker.
Структура файлів у Vite
- src/components/PrimeCounter.jsx
- src/workers/prime.worker.js
Worker файл у Vite
// src/workers/prime.worker.js
self.onmessage = function(event) {
const limit = event.data;
let count = 0;
for (let number = 2; number <= limit; number += 1) {
let isPrime = true;
for (let divisor = 2; divisor * divisor <= number; divisor += 1) {
if (number % divisor === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
count += 1;
}
}
self.postMessage(count);
};
Компонент у Vite через ?worker
import { useEffect, useRef, useState } from "react";
import PrimeWorker from "../workers/prime.worker.js?worker";
export default function PrimeCounter() {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
useEffect(function() {
workerRef.current = new PrimeWorker();
workerRef.current.onmessage = function(event) {
setResult(event.data);
};
return function() {
workerRef.current.terminate();
};
}, []);
function handleStart() {
workerRef.current.postMessage(50000);
}
return (
<div>
<button onClick={handleStart}>Start counting</button>
{result !== null && <p>Primes: {result}</p>}
</div>
);
}
Що важливо у Vite для Workers
- Зручно використовувати
?worker -
Worker краще тримати в окремій папці, наприклад
src/workers - Не забувай викликати
terminate() - Worker не повинен містити код, який залежить від DOM
7. Web Workers у Next.js
У Next.js працювати з Worker теж можна, але потрібно пам'ятати, що це браузерний API.
Отже, Worker треба створювати тільки у Client Component.
Головні правила для Next.js
- Компонент має бути Client Component
- Додай
"use client";на початок файла - Створюй worker усередині useEffect або по кліку
- Не створюй worker у Server Component
Структура файлів у Next.js
- app/components/PrimeCounter.jsx
- app/workers/prime.worker.js
Worker файл для Next.js
// app/workers/prime.worker.js
self.onmessage = function(event) {
const limit = event.data;
let count = 0;
for (let number = 2; number <= limit; number += 1) {
let isPrime = true;
for (let divisor = 2; divisor * divisor <= number; divisor += 1) {
if (number % divisor === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
count += 1;
}
}
self.postMessage(count);
};
Client Component у Next.js
"use client";
import { useEffect, useRef, useState } from "react";
export default function PrimeCounter() {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(function() {
workerRef.current = new Worker(
new URL("../workers/prime.worker.js", import.meta.url)
);
workerRef.current.onmessage = function(event) {
setResult(event.data);
setIsLoading(false);
};
workerRef.current.onerror = function(error) {
console.error("Помилка worker:", error);
setIsLoading(false);
};
return function() {
workerRef.current.terminate();
};
}, []);
function handleStart() {
setIsLoading(true);
workerRef.current.postMessage(100000);
}
return (
<div>
<button onClick={handleStart}>Run heavy task</button>
{isLoading && <p>Loading...</p>}
{result !== null && <p>Result: {result}</p>}
</div>
);
}
У чому різниця між Vite і Next.js для Workers
-
У Vite частіше використовують імпорт через
?worker -
У Next.js зазвичай створюють Worker через
new Worker(new URL(..., import.meta.url)) - У Next.js обов'язково пам'ятай про Client Component
- У Vite менше SSR-обмежень у типовому SPA-сценарії
8. WebSocket — що це таке
WebSocket — це технологія для постійного двостороннього з'єднання між клієнтом і сервером.
На відміну від звичайного HTTP, де клієнт відправив запит і отримав відповідь, WebSocket дозволяє серверу теж самостійно надсилати дані клієнту.
Де використовується WebSocket
- Чати
- Нотифікації у реальному часі
- Онлайн-ігри
- Live dashboards
- Трекінг статусів замовлення
- Оновлення біржових або системних даних у реальному часі
Життєвий цикл WebSocket
- Створення через
new WebSocket(url) - Подія
open— з'єднання встановлено - Подія
message— прийшло повідомлення - Подія
error— помилка - Подія
close— з'єднання закрито
Базовий приклад WebSocket
const socket = new WebSocket("wss://example.com/socket");
socket.onopen = function() {
console.log("З'єднання відкрито");
socket.send("Hello server");
};
socket.onmessage = function(event) {
console.log("Повідомлення від сервера:", event.data);
};
socket.onerror = function(error) {
console.error("Помилка сокета:", error);
};
socket.onclose = function() {
console.log("З'єднання закрито");
};
Чому часто використовують JSON
Зазвичай через WebSocket передають не просто рядок, а JSON-рядок, який містить тип події та payload.
Приклад відправки JSON
const socket = new WebSocket("wss://example.com/chat");
socket.onopen = function() {
const message = {
type: "chat_message",
payload: {
text: "Привіт",
roomId: "general"
}
};
socket.send(JSON.stringify(message));
};
Приклад читання JSON
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === "chat_message") {
console.log("Нове повідомлення:", data.payload.text);
}
};
Готові стани WebSocket
WebSocket.CONNECTING // 0
WebSocket.OPEN // 1
WebSocket.CLOSING // 2
WebSocket.CLOSED // 3
Перевірка перед send
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ping" }));
}
React приклад WebSocket
import { useEffect, useRef, useState } from "react";
export default function ChatExample() {
const socketRef = useRef(null);
const [messages, setMessages] = useState([]);
useEffect(function() {
socketRef.current = new WebSocket("wss://example.com/chat");
socketRef.current.onopen = function() {
console.log("Socket connected");
};
socketRef.current.onmessage = function(event) {
const data = JSON.parse(event.data);
setMessages(function(previousMessages) {
return [...previousMessages, data];
});
};
socketRef.current.onerror = function(error) {
console.error("Socket error:", error);
};
socketRef.current.onclose = function() {
console.log("Socket closed");
};
return function() {
socketRef.current.close();
};
}, []);
function sendMessage() {
if (socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify({
type: "chat_message",
payload: {
text: "Hello"
}
})
);
}
}
return (
<div>
<button onClick={sendMessage}>Send</button>
<ul>
{messages.map(function(message, index) {
return <li key={index}>{JSON.stringify(message)}</li>;
})}
</ul>
</div>
);
}
Чому useRef зручний для WebSocket
- Сокет не повинен створюватися на кожен ререндер
- useRef зберігає посилання між рендерами
- Його зручно закривати в cleanup функції useEffect
Простий приклад reconnect
import { useEffect, useRef, useState } from "react";
export default function ReconnectSocket() {
const socketRef = useRef(null);
const timeoutRef = useRef(null);
const [status, setStatus] = useState("disconnected");
useEffect(function() {
function connect() {
setStatus("connecting");
socketRef.current = new WebSocket("wss://example.com/socket");
socketRef.current.onopen = function() {
setStatus("connected");
};
socketRef.current.onclose = function() {
setStatus("disconnected");
// Проста спроба перепідключення через 3 секунди
timeoutRef.current = setTimeout(function() {
connect();
}, 3000);
};
socketRef.current.onerror = function(error) {
console.error("Socket error:", error);
};
}
connect();
return function() {
clearTimeout(timeoutRef.current);
if (socketRef.current) {
socketRef.current.close();
}
};
}, []);
return <p>Status: {status}</p>;
}
Поширені помилки з WebSocket
- Створюють новий socket на кожен ререндер
- Не закривають socket при демонтажі компонента
- Відправляють повідомлення до відкриття з'єднання
- Не перевіряють JSON.parse через try/catch
- Не враховують reconnect логіку
9. WebSocket у Vite
У Vite WebSocket-клієнт підключається так само, як у звичайному React-проєкті.
Додаткового спеціального налаштування для браузерного WebSocket-клієнта зазвичай не потрібно.
Vite React приклад
// src/components/Notifications.jsx
import { useEffect, useRef, useState } from "react";
export default function Notifications() {
const socketRef = useRef(null);
const [notifications, setNotifications] = useState([]);
useEffect(function() {
socketRef.current = new WebSocket("wss://example.com/notifications");
socketRef.current.onmessage = function(event) {
const notification = JSON.parse(event.data);
setNotifications(function(previousNotifications) {
return [notification, ...previousNotifications];
});
};
return function() {
socketRef.current.close();
};
}, []);
return (
<ul>
{notifications.map(function(item, index) {
return <li key={index}>{item.text}</li>;
})}
</ul>
);
}
10. WebSocket у Next.js
У Next.js WebSocket-клієнт теж потрібно створювати тільки у Client Component, тому що це браузерний API.
Приклад для Next.js App Router
"use client";
import { useEffect, useRef, useState } from "react";
export default function LiveNotifications() {
const socketRef = useRef(null);
const [items, setItems] = useState([]);
useEffect(function() {
socketRef.current = new WebSocket("wss://example.com/notifications");
socketRef.current.onopen = function() {
console.log("Socket opened");
};
socketRef.current.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
setItems(function(previousItems) {
return [data, ...previousItems];
});
} catch (error) {
console.error("Помилка парсингу socket message:", error);
}
};
socketRef.current.onclose = function() {
console.log("Socket closed");
};
return function() {
if (socketRef.current) {
socketRef.current.close();
}
};
}, []);
return (
<div>
<h2>Live notifications</h2>
<ul>
{items.map(function(item, index) {
return <li key={index}>{item.text}</li>;
})}
</ul>
</div>
);
}
Що важливо для Next.js
- Потрібен
"use client" - Socket створюється у useEffect або після взаємодії користувача
- Не можна використовувати WebSocket у Server Component як браузерний клієнтський API
11. Простий тестовий WebSocket сервер на Node.js
Якщо ти хочеш локально потренувати WebSocket, можна швидко підняти простий сервер.
Встановлення
npm init -y
npm install ws
server.js
const WebSocket = require("ws");
const server = new WebSocket.Server({ port: 4000 });
server.on("connection", function(socket) {
console.log("Клієнт підключився");
socket.send(
JSON.stringify({
type: "system",
text: "Welcome to WebSocket server"
})
);
socket.on("message", function(message) {
console.log("Отримано від клієнта:", message.toString());
socket.send(
JSON.stringify({
type: "echo",
text: message.toString()
})
);
});
socket.on("close", function() {
console.log("Клієнт відключився");
});
});
console.log("WebSocket server started on ws://localhost:4000");
Клієнт для локального тесту
const socket = new WebSocket("ws://localhost:4000");
socket.onopen = function() {
socket.send("Hello from client");
};
socket.onmessage = function(event) {
console.log("Server says:", event.data);
};
12. Порівняння: Storage vs Web Workers vs WebSocket
- Storage — зберігання даних у браузері
- Web Worker — винесення важких задач у фоновий потік
- WebSocket — двосторонній зв'язок із сервером у реальному часі
Коли що використовувати
- Потрібно зберегти тему, токен, чернетку — Storage
- Потрібно обробити великий масив без лагів UI — Web Worker
- Потрібен чат або live updates — WebSocket
13. Типові помилки trainee-розробника
-
Працювати з localStorage у Next.js без
"use client" - Зберігати об'єкти без
JSON.stringify - Не перевіряти результат
JSON.parse - Не очищати WebSocket і Worker у cleanup
- Створювати socket або worker на кожен ререндер
- Пробувати працювати з DOM усередині Worker
-
Відправляти дані у WebSocket до події
open - Плутати тимчасове сховище sessionStorage з постійним localStorage
14. Рекомендована структура у React / Vite
src/
components/
ThemeSwitcher.jsx
Notifications.jsx
PrimeCounter.jsx
workers/
prime.worker.js
hooks/
useLocalStorage.js
useWebSocket.js
15. Рекомендована структура у Next.js App Router
app/
components/
ThemeClient.jsx
LiveNotifications.jsx
PrimeCounter.jsx
workers/
prime.worker.js
hooks/
useLocalStorage.js
useWebSocket.js
16. Корисний кастомний хук useLocalStorage
import { useEffect, useState } from "react";
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(function() {
if (typeof window === "undefined") {
return initialValue;
}
try {
const storedValue = localStorage.getItem(key);
if (storedValue === null) {
return initialValue;
}
return JSON.parse(storedValue);
} catch (error) {
console.error("Помилка читання localStorage:", error);
return initialValue;
}
});
useEffect(function() {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error("Помилка запису localStorage:", error);
}
}, [key, value]);
return [value, setValue];
}
17. Корисний кастомний хук useWebSocket
import { useEffect, useRef, useState } from "react";
export function useWebSocket(url) {
const socketRef = useRef(null);
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState("idle");
useEffect(function() {
const socket = new WebSocket(url);
socketRef.current = socket;
setStatus("connecting");
socket.onopen = function() {
setStatus("open");
};
socket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
setMessages(function(previousMessages) {
return [...previousMessages, data];
});
} catch (error) {
console.error("Помилка JSON у WebSocket:", error);
}
};
socket.onerror = function(error) {
console.error("WebSocket error:", error);
setStatus("error");
};
socket.onclose = function() {
setStatus("closed");
};
return function() {
socket.close();
};
}, [url]);
function sendMessage(payload) {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(payload));
}
}
return {
messages: messages,
status: status,
sendMessage: sendMessage
};
}
18. Швидка шпаргалка
localStorage— довге збереження даних-
sessionStorage— дані до закриття вкладки -
JSON.stringify()— перед збереженням об'єкта JSON.parse()— після читання об'єктаnew Worker()— створення worker-
postMessage()— передача даних між потоками worker.terminate()— завершення workernew WebSocket(url)— створення сокетаsocket.send()— відправка данихsocket.close()— закриття з'єднання- У Next.js для всіх цих браузерних API потрібен Client Component
19. Повна картина для фронтендера
Storage відповідає за локальне збереження даних у браузері.
Web Workers допомагають не блокувати інтерфейс важкими обчисленнями.
WebSocket дає змогу будувати функціонал у реальному часі.
У Vite все це зазвичай підключається простіше, бо типовий сценарій — це клієнтський застосунок.
У Next.js потрібно пам'ятати про межу між сервером і браузером, тому доступ до цих API робиться через Client Components.
Якщо ти добре зрозумієш ці три теми, тобі буде набагато легше писати більш "живі", продуктивні і реалістичні React-застосунки.
Комплексне практичне завдання — Real-Time Analytics App
Створи SPA-додаток, який поєднує:
- Персистентний стан (localStorage)
- Real-time комунікацію (WebSocket)
- Фонові обчислення (Web Worker)
Завдання складається з трьох частин, які логічно повʼязані між собою.
Частина 1 — Локальний менеджер стану (Web Storage)
Реалізуй систему керування налаштуваннями користувача з автоматичною синхронізацією між вкладками.
Мета
Закріпити: localStorage, JSON, подію storage, часткове оновлення стану.
Функціональні вимоги
- Зберігати обʼєкт settings (імʼя, тема, мова, автооновлення)
- Оновлювати тільки змінене поле (partial update)
- Синхронізувати зміни між вкладками через storage event
- Відновлювати стан при reload
Додаткові умови
- Якщо localStorage пошкоджений — обробити помилку
- Не допускати збереження порожнього імені
- Показувати статус "Saved"
UI
- Input (імʼя)
- Select (тема)
- Checkbox (автооновлення)
- Статус збереження
❗ Заборонено зберігати кожне натискання клавіші — використовуй debounce через setTimeout
Частина 2 — Система повідомлень у реальному часі
Реалізуй центр сповіщень з автоматичним перепідключенням.
Мета
Закріпити: WebSocket, управління станом зʼєднання, reconnect logic, буферизацію повідомлень.
Функціональні вимоги
- Кнопка Connect / Disconnect
- Статус зʼєднання (Connecting / Connected / Disconnected)
- Черга повідомлень якщо зʼєднання закрите
- Автоматичне перепідключення через 3 секунди
- Збереження історії повідомлень у localStorage
Додатково
- Обмежити історію 50 останніми повідомленнями
- Додати timestamp до кожного повідомлення
- Обробити подію error
Логіка
- При reconnect не втрачати чергу
- Не створювати кілька WebSocket одночасно
💡 Це вже наближено до production-поведінки
Частина 3 — Аналітика великих даних (Web Worker)
Реалізуй модуль аналітики, який:
- Генерує великий масив обʼєктів (наприклад, транзакції)
- Передає їх у Worker
- Worker рахує:
- загальну суму
- середнє значення
- максимум
- кількість операцій
Мета
Закріпити: postMessage, двосторонню комунікацію, error handling, terminate().
Функціональні вимоги
- Показати статус "Processing..."
- Блокувати кнопку під час обробки
- Обробити помилки Worker
- Знищувати Worker після завершення
Додаткове ускладнення
- Передавати масив частинами (batch processing)
- Показувати прогрес у відсотках
❗ Заборонено виконувати обчислення у головному потоці
Очікуваний результат
- Стабільний стан із синхронізацією вкладок
- Real-time система з reconnect логікою
- Фонова аналітика без блокування UI
Це вже повноцінна модель архітектури сучасного SPA.
React tools: npm, Webpack, Babel, ESLint — що повинен знати Front-end розробник
React-проєкт майже ніколи не існує сам по собі. Навколо React зазвичай є набір інструментів, які допомагають встановлювати залежності, збирати код, перетворювати сучасний JavaScript у зрозумілий для браузера формат і стежити за якістю коду.
До базового набору таких інструментів належать npm, Webpack, Babel і ESLint. Навіть якщо у сучасних проєктах частину цієї роботи вже приховують Vite або Next.js, фронтендеру важливо розуміти, що саме відбувається під капотом.
Що саме ми будемо розбирати
- npm — менеджер пакетів і скриптів
- Webpack — збирач модулів і ресурсів
- Babel — транспілятор сучасного JavaScript
- ESLint — інструмент перевірки якості коду
- Як це працює у звичайному React/Vite проєкті
- Як це працює у Next.js
- Які файли за що відповідають
- Типові помилки trainee-рівня
1. npm — менеджер пакетів і скриптів
npm — це інструмент, який іде разом із Node.js. Він дозволяє встановлювати бібліотеки, керувати залежностями проєкту та запускати команди через scripts у файлі package.json.
Простими словами: npm відповідає за те, щоб твій
React-проєкт мав потрібні пакети, а також щоб ти міг
запускати команди типу npm run dev,
npm run build або npm run lint.
Що важливо знати про npm
-
package.json— головний файл конфігурації проєкту -
dependencies— пакети для роботи застосунку devDependencies— пакети для розробки-
scripts— набір команд для запуску проєкту -
package-lock.json— фіксує точні версії залежностей -
node_modules— папка з усіма встановленими пакетами
Створення package.json
npm init -y
Команда створює базовий файл package.json із
дефолтними значеннями.
Встановлення пакетів
npm install react react-dom
npm install -D vite
npm install -D eslint
npm install -D webpack webpack-cli
npm install -D @babel/core @babel/preset-env @babel/preset-react babel-loader
npm install або коротко
npm i встановлює пакети.
Прапорець -D означає, що пакет буде записаний у
devDependencies, тобто потрібний лише під час
розробки.
Приклад package.json
{
"name": "react-tools-guide",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"eslint": "^9.0.0",
"vite": "^6.0.0"
}
}
Що тут важливо розуміти
name— назва проєктуversion— версія проєкту-
private— заборона випадкової публікації в npm -
scripts— команди, які запускаються черезnpm run dependencies— основні бібліотеки-
devDependencies— інструменти збірки, лінтингу, тестування
Основні npm-команди
npm install
npm install react
npm install axios
npm install -D eslint
npm uninstall axios
npm run dev
npm run build
npm run lint
npm install без пакета встановлює всі
залежності з package.json.
npm run dev запускає скрипт dev із
секції scripts.
Різниця між dependencies і devDependencies
{
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"axios": "^1.0.0"
},
"devDependencies": {
"eslint": "^9.0.0",
"webpack": "^5.0.0",
"@babel/core": "^7.0.0"
}
}
У dependencies кладуть те, що реально
використовується застосунком.
У devDependencies кладуть те, що допомагає
розробляти проєкт: лінтери, збирачі, тестові фреймворки,
транспілятори.
Типові помилки з npm
- Редагують
node_modulesвручну -
Видаляють
package-lock.jsonбез розуміння наслідків -
Не розуміють різницю між
dependenciesіdevDependencies -
Запускають скрипти, не перевіряючи що реально записано в
scripts -
Плутають
npm installіnpm run
2. Webpack — збирач модулів
Webpack — це інструмент, який бере багато файлів проєкту і збирає їх у меншу кількість готових файлів для браузера. Він уміє працювати не лише з JavaScript, а й із CSS, зображеннями, шрифтами та іншими ресурсами.
Простими словами: Webpack будує залежності між файлами, обробляє їх через loaders і plugins, а потім створює фінальну збірку.
Що важливо знати про Webpack
entry— точка входу в застосунок-
output— куди покласти результат збірки module.rules— правила обробки файлів-
loader— спосіб обробки певного типу файлу plugin— додаткова логіка збірки-
mode— режимdevelopmentабоproduction
Навіщо Webpack у React-проєкті
- збирає модулі в один граф залежностей
- дозволяє імпортувати CSS у JavaScript
- дозволяє імпортувати картинки
- разом із Babel трансформує JSX
- може мініфікувати код для production
- може запускати dev server
Мінімальна установка Webpack для React
npm install -D webpack webpack-cli webpack-dev-server
npm install -D html-webpack-plugin
npm install -D babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install react react-dom
Базова структура проєкту з Webpack
project/
package.json
webpack.config.js
public/
index.html
src/
index.js
App.jsx
Приклад webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "development",
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
clean: true
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: "babel-loader"
}
]
},
resolve: {
extensions: [".js", ".jsx"]
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html"
})
],
devServer: {
port: 3000,
open: true
}
};
Пояснення до webpack.config.js
-
entry— головний JS-файл, із якого починається аналіз залежностей output.path— папка результату збіркиoutput.filename— ім’я вихідного файлу-
clean: true— очищає папкуdistперед новою збіркою rules— інструкції, як обробляти файлиbabel-loader— передає JS/JSX у Babel-
HtmlWebpackPlugin— генерує HTML із підключеним bundle -
devServer— локальний сервер для розробки
Приклад public/index.html
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React + Webpack</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
Приклад src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Приклад src/App.jsx
export default function App() {
return (
<div>
<h1>Hello from React + Webpack</h1>
<p>Це базовий приклад React-застосунку.</p>
</div>
);
}
Скрипти для запуску Webpack-проєкту
{
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production"
}
}
Коли фронтендеру реально потрібне розуміння Webpack
- коли треба зрозуміти, як працює збірка старого React-проєкту
- коли треба додати обробку SVG, CSS modules, зображень
- коли у проєкті кастомна конфігурація збірки
- коли виникають помилки зі шляхами, alias або loaders
- коли треба мігрувати зі старого проєкту на новіший стек
Типові помилки з Webpack
- не розуміють різницю між loader і plugin
-
забувають додати
resolve.extensionsдля.jsx -
не виключають
node_modulesз Babel-обробки - неправильно вказують шлях до шаблону HTML
- очікують, що Webpack сам зрозуміє JSX без Babel
3. Babel — транспілятор JavaScript
Babel — це інструмент, який перетворює сучасний JavaScript і JSX у код, який браузер або середовище виконання можуть зрозуміти.
Наприклад, Babel може перетворити JSX у звичайні виклики функцій React, а також трансформувати нові можливості JavaScript у більш сумісний код.
Що Babel робить у React
- перетворює JSX у JavaScript
- трансформує сучасний синтаксис
- дозволяє використовувати presets і plugins
- часто працює разом із Webpack або іншими bundlers
Установка Babel для React
npm install -D @babel/core
npm install -D @babel/preset-env
npm install -D @babel/preset-react
npm install -D babel-loader
Приклад .babelrc
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
Це означає:
-
@babel/preset-env— підтримка сучасного JavaScript @babel/preset-react— підтримка JSX
Те саме через babel.config.json
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
У невеликих проєктах часто використовують або
.babelrc, або babel.config.json.
Приклад JSX до Babel-трансформації
function App() {
return <h1>Hello</h1>;
}
Ідея того, у що Babel це перетворює
function App() {
return React.createElement("h1", null, "Hello");
}
На практиці конкретний результат може відрізнятися залежно від версії React і налаштувань Babel, але суть саме така: JSX не виконується напряму браузером, його треба перетворити.
Приклад сучасного JavaScript
const user = {
name: "Viktor",
contact: {
email: "test@example.com"
}
};
const email = user?.contact?.email ?? "No email";
console.log(email);
Babel допомагає працювати з новими можливостями JavaScript у старіших середовищах, якщо проєкт налаштований на це.
Коли Babel може бути майже непомітним
У сучасному React/Vite або Next.js ти часто не налаштовуєш Babel вручну. Але це не означає, що його роль неважлива. Просто інструменти вищого рівня часто вже мають готову конфігурацію або використовують альтернативні трансформери, які виконують схожу задачу.
Типові помилки з Babel
- очікують, що JSX працюватиме без жодної трансформації
- плутають Babel із Webpack
-
не встановлюють
@babel/preset-reactдля React-коду - думають, що Babel сам запускає застосунок
- не розуміють, що Babel тільки трансформує код, а не збирає весь проєкт
4. ESLint — перевірка якості коду
ESLint — це інструмент, який аналізує код і показує потенційні помилки, погані практики або невідповідність стилю.
Він не запускає застосунок, а перевіряє код до запуску або під час розробки. Це дуже корисно, бо багато помилок можна побачити ще до браузера.
Що вміє ESLint
- знаходити синтаксичні й логічні проблеми
- знаходити невикористані змінні
- контролювати стиль написання коду
- допомагати підтримувати однаковий стиль у команді
- працювати разом із React-правилами
Встановлення ESLint
npm install -D eslint
npx eslint --init
Ініціалізація може створити конфігурацію автоматично, але в реальних проєктах часто використовують уже готові конфіги або пишуть їх вручну.
Приклад eslint.config.js
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{
ignores: ["dist"]
},
{
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: globals.browser
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"react-refresh/only-export-components": "warn"
}
}
];
Що тут відбувається
ignores— які файли не перевіряти-
files— до яких файлів застосовувати правила -
globals— які глобальні об’єкти вважати допустимими -
plugins— підключення додаткових правил -
rules— конкретні обмеження та перевірки
Приклади правил ESLint
{
"rules": {
"no-unused-vars": "error",
"no-console": "warn",
"eqeqeq": "error"
}
}
Що означають ці правила
-
no-unused-vars— забороняє невикористані змінні -
no-console— попереджає проconsole.log -
eqeqeq— вимагає використовувати===замість==
Приклад коду, який ESLint підсвітить
function Example() {
const name = "Viktor";
const age = 25;
console.log(name);
return <div>Hello</div>;
}
Якщо правило no-unused-vars увімкнене, ESLint
підсвітить age, бо змінна не використовується.
Запуск ESLint
npx eslint .
npx eslint src
npm run lint
Скрипт для package.json
{
"scripts": {
"lint": "eslint ."
}
}
Типові помилки з ESLint
- ігнорують попередження і накопичують технічний борг
- вимикають правила замість того, щоб зрозуміти проблему
- не додають React-специфічні плагіни
- не запускають лінт перед комітом
- плутають ESLint і Prettier
5. ESLint і Prettier — це не одне й те саме
Дуже важливий момент для trainee: ESLint і Prettier часто працюють разом, але це різні інструменти.
- ESLint — шукає проблеми в коді
- Prettier — автоматично форматує код
Наприклад, ESLint може сказати, що змінна не використовується, а Prettier просто красиво розставить пробіли та переноси рядків.
Приклад того, що робить Prettier
const user={name:"Viktor",age:25}
Після форматування Prettier це стане більш читабельним:
const user = {
name: "Viktor",
age: 25
};
6. Як ці інструменти пов’язані між собою
У типовому React-проєкті ці інструменти працюють як ланцюжок:
- npm встановлює потрібні пакети та запускає команди
- Webpack або інший bundler збирає проєкт
- Babel трансформує JavaScript і JSX
- ESLint перевіряє код на помилки та погані практики
Схема роботи
Ти пишеш React-код
↓
ESLint перевіряє код
↓
Babel трансформує JSX і сучасний JS
↓
Webpack або інший bundler збирає файли
↓
Браузер отримує готову збірку
7. React tools у Vite
Vite — це сучасний інструмент для розробки, який спрощує налаштування React-проєкту. У ньому багато речей уже готові з коробки, тому вручну конфігурувати Webpack або Babel зазвичай не потрібно.
Створення React-проєкту на Vite
npm create vite@latest my-app
cd my-app
npm install
npm run dev
Вибір шаблону
Під час створення проєкту Vite запропонує вибрати:
- framework: React
- variant: JavaScript або TypeScript
Типова структура React + Vite
my-app/
node_modules/
public/
src/
assets/
App.jsx
main.jsx
.gitignore
eslint.config.js
index.html
package.json
vite.config.js
Що тут за що відповідає
src/main.jsx— вхідна точка Reactsrc/App.jsx— головний компонентindex.html— HTML-шаблонvite.config.js— конфігурація Viteeslint.config.js— конфігурація ESLintpackage.json— залежності й скрипти
Приклад src/main.jsx у Vite
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Приклад vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});
Плагін @vitejs/plugin-react додає підтримку
React і JSX.
Як у Vite поводяться npm, Webpack, Babel, ESLint
- npm — використовується повноцінно
- Webpack — зазвичай не використовується, Vite має свій підхід до збірки
- Babel — часто прихований усередині інструментів або частково замінений швидшими механізмами
- ESLint — підключається окремо і використовується як звичайно
Скрипти package.json у Vite
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
}
}
Установка ESLint у Vite-проєкті
npm install -D eslint
npm install -D eslint-plugin-react-hooks
npm install -D eslint-plugin-react-refresh
npm run lint
Alias у Vite
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
}
});
Це дозволяє писати імпорти типу
@/components/Button замість довгих відносних
шляхів.
Коли у Vite може знадобитися знання Webpack і Babel
Навіть якщо у Vite ти не пишеш конфігурацію Webpack вручну, знання цих інструментів усе одно корисне. Воно допомагає зрозуміти, чому працює JSX, звідки береться dev/build, як обробляються модулі і чому старі проєкти можуть бути налаштовані зовсім інакше.
8. React tools у Next.js
Next.js — це React-фреймворк, який уже містить багато готової інфраструктури: маршрутизацію, серверний рендеринг, оптимізацію, збірку і часто вже налаштований ESLint.
У Next.js значна частина роботи npm, Babel, bundler-логіки і лінтингу вже захована в самому фреймворку. Але розуміння принципів усе одно дуже важливе.
Створення Next.js-проєкту
npx create-next-app@latest my-next-app
cd my-next-app
npm run dev
Типова структура Next.js (App Router)
my-next-app/
app/
page.js
layout.js
globals.css
public/
.eslintrc.json або eslint.config.js
next.config.js
package.json
Що тут за що відповідає
app/— маршрути і сторінкиapp/page.js— головна сторінкаapp/layout.js— загальний layoutpublic/— статичні файлиnext.config.js— конфігурація Next.jspackage.json— залежності й скрипти
Скрипти package.json у Next.js
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
Як у Next.js поводяться npm, Webpack, Babel, ESLint
- npm — використовується для залежностей і скриптів
- Webpack — історично використовується всередині Next.js, але ти рідко налаштовуєш його вручну
- Babel — часто вже інтегрований або прихований у фреймворку
- ESLint — часто вже налаштований під час створення проєкту
Приклад app/page.js
export default function HomePage() {
return (
<main>
<h1>Hello from Next.js</h1>
<p>Це стартова сторінка.</p>
</main>
);
}
Приклад next.config.js
/** @type {import("next").NextConfig} */
const nextConfig = {
reactStrictMode: true
};
module.exports = nextConfig;
Як запускати ESLint у Next.js
npm run lint
У багатьох Next.js-проєктах ця команда вже готова з коробки.
Коли в Next.js може знадобитися додатковий ESLint-конфіг
{
"extends": ["next/core-web-vitals"]
}
Такий конфіг часто зустрічається у старішому форматі конфігурації ESLint і додає правила, рекомендовані для Next.js.
Alias у Next.js через jsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
Або alias тільки для src-структури
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Що важливо пам’ятати про Next.js
- ти зазвичай не конфігуруєш Webpack вручну на старті
- ти не пишеш Babel-конфіг у більшості базових випадків
- npm-скрипти й ESLint залишаються дуже важливими
- розуміння інструментів під капотом допомагає дебажити складні випадки
9. Vite vs Next.js — у чому різниця щодо цих інструментів
npm у Vite і Next.js
У Vite і Next.js npm використовується майже однаково: для
встановлення залежностей та запуску скриптів через
package.json.
npm install
npm run dev
npm run build
npm run lint
Webpack у Vite і Next.js
У Vite ти зазвичай не працюєш із Webpack, бо Vite використовує інший підхід до dev-сервера і збірки.
У Next.js Webpack довго був внутрішнім механізмом збірки, але на практиці ти рідко пишеш його конфіг вручну, якщо проєкт типовий.
Тобто:
- Vite — Webpack знати корисно, але не обов’язково щодня налаштовувати
- Next.js — Webpack часто захований під капотом
Babel у Vite і Next.js
У Vite і Next.js Babel або аналогічні трансформаційні
механізми зазвичай уже інтегровані. Ти рідко створюєш
.babelrc у простому проєкті.
Але знати, що JSX і сучасний JavaScript не магія, а результат трансформації, дуже важливо.
ESLint у Vite і Next.js
У Vite ESLint зазвичай додається окремо або приходить із шаблоном.
У Next.js ESLint часто вже налаштований під час створення проєкту.
В обох випадках логіка одна: він перевіряє код на помилки та погані практики.
Коротке порівняння
- Vite простіший для старту звичайного React-проєкту
- Next.js дає більше готової інфраструктури
- Webpack у чистому вигляді частіше бачать у старіших або кастомних проєктах
- Babel важливий концептуально в обох підходах
- npm і ESLint потрібні всюди
10. Що, де і як створювати на практиці
Якщо у тебе React + Vite
-
створюєш проєкт через
npm create vite@latest -
package.json— керує залежностями та скриптами -
vite.config.js— базова конфігурація Vite eslint.config.js— правила лінтингуsrc/main.jsx— вхідна точкаsrc/App.jsx— головний компонент
Якщо у тебе Next.js
-
створюєш проєкт через
npx create-next-app@latest -
package.json— керує залежностями та скриптами -
next.config.js— конфігурація фреймворку -
eslint.config.jsабо.eslintrc.json— правила лінтингу app/page.js— сторінкаapp/layout.js— спільний layout
Якщо у тебе старий React-проєкт із ручною збіркою
package.json— залежності і scripts-
webpack.config.js— ручна конфігурація збірки -
.babelrcабоbabel.config.json— налаштування Babel -
.eslintrcабоeslint.config.js— правила ESLint src/index.js— вхідна точкаpublic/index.html— HTML-шаблон
11. Повний мінімальний приклад React + Webpack + Babel + ESLint
Структура
my-react-webpack-app/
public/
index.html
src/
App.jsx
index.js
package.json
webpack.config.js
.babelrc
eslint.config.js
package.json
{
"name": "my-react-webpack-app",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production",
"lint": "eslint ."
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^9.0.0",
"eslint": "^9.0.0",
"html-webpack-plugin": "^5.0.0",
"webpack": "^5.0.0",
"webpack-cli": "^6.0.0",
"webpack-dev-server": "^5.0.0"
}
}
.babelrc
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
clean: true
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: "babel-loader"
}
]
},
resolve: {
extensions: [".js", ".jsx"]
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html"
})
],
devServer: {
port: 3000,
open: true
}
};
public/index.html
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Tools Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
src/App.jsx
export default function App() {
const title = "React tools guide";
return (
<section>
<h1>{title}</h1>
<p>npm, Webpack, Babel та ESLint працюють разом.</p>
</section>
);
}
eslint.config.js
import js from "@eslint/js";
import globals from "globals";
export default [
{
ignores: ["dist"]
},
{
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: globals.browser
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": "error"
}
}
];
Команди запуску
npm install
npm run start
npm run build
npm run lint
12. Типові trainee-питання і короткі відповіді
Чи потрібен мені Webpack, якщо я працюю з Vite?
Щодня налаштовувати — ні. Але розуміти, що робить bundler, дуже бажано.
Чи треба мені вчити Babel, якщо все і так працює?
Так. Не обов’язково одразу заглиблюватися в складні плагіни, але треба розуміти, що JSX і сучасний JavaScript перед виконанням проходять трансформацію.
Чи можна без ESLint?
Можна, але це погана практика. ESLint дуже допомагає ловити помилки рано.
Чи npm це те саме, що Node.js?
Ні. Node.js — це середовище виконання JavaScript, а npm — менеджер пакетів, який постачається разом із Node.js.
Чи потрібен Babel окремо в Next.js?
У більшості базових випадків — ні, бо багато що вже налаштовано автоматично. Але знати його роль усе одно треба.
13. Типові помилки trainee-рівня в реальних проєктах
-
не читають
package.jsonперед запуском команд - не розуміють, яка саме команда запускає dev, build або lint
- бояться файлів конфігурації й не намагаються їх читати
- плутають bundler, transpiler і linter
- не знають, де шукати точку входу проєкту
- не розуміють, чому JSX не може виконуватись сам по собі
- ігнорують ESLint-помилки
- змінюють конфіг навмання, не розуміючи, що саме зламали
14. Як це все пояснити однією простою картиною
Уяви, що ти створюєш React-застосунок.
- npm приносить інструменти та бібліотеки
- Babel перекладає сучасний JS і JSX у зрозумілий код
- Webpack або інший bundler збирає все разом
- ESLint перевіряє, чи не допустив ти помилок
У Vite і Next.js частина цієї логіки вже прихована й автоматизована, але принципи залишаються тими самими.
15. Повна картина для фронтендера
npm — це встановлення пакетів і запуск команд.
Webpack — це збірка модулів і ресурсів у готовий застосунок.
Babel — це перетворення JSX і сучасного JavaScript.
ESLint — це контроль якості коду та ранній пошук помилок.
Vite спрощує налаштування і приховує частину складності.
Next.js дає ще більше готової інфраструктури поверх React.
Але щоб упевнено працювати з React-проєктами, важливо розуміти не тільки як запускати команду, а й що саме відбувається під капотом.
16. Шпаргалка по командах
npm init -y
npm install
npm install react react-dom
npm install -D vite
npm install -D eslint
npm install -D webpack webpack-cli webpack-dev-server
npm install -D @babel/core @babel/preset-env @babel/preset-react babel-loader
npm run dev
npm run build
npm run preview
npm run lint
npx eslint .
npx create-next-app@latest my-next-app
npm create vite@latest my-app
17. Короткий висновок
Якщо ти працюєш із сучасним React, то найчастіше взаємодіятимеш із npm і ESLint напряму, а Webpack і Babel часто будуть заховані всередині Vite, Next.js або іншого інструмента.
Але знання всіх чотирьох інструментів робить тебе набагато впевненішим у роботі, особливо коли треба читати чужий проєкт, виправляти конфігурацію або розбиратися, чому щось зламалося.
"Пощупати" npm, Webpack, ESLint та Next.js
Сьогодні тут пусто
Node.js — що повинен знати Front-end розробник
Node.js — це середовище виконання JavaScript поза браузером. Воно дозволяє запускати JS на сервері, працювати з файлами, змінними середовища, пакетами та створювати backend-логіку.
Для front-end розробника Node.js важливий не стільки як інструмент написання складного backend, скільки як база для роботи з React-проєктами, npm-пакетами, збіркою, запуском dev server, скриптами, SSR і API.
Основні речі, які треба розуміти в Node.js
- Node.js запускає JavaScript поза браузером.
- Через Node.js працюють npm, Vite, Next.js, ESLint, Prettier, Jest, Vitest та інші інструменти.
- У Node.js є CommonJS і ES Modules.
- У Node.js є доступ до process.env для змінних середовища.
- У Node.js постійно використовується асинхронність: Promise, async/await.
- Багато фронтенд-інструментів запускаються через package.json scripts.
Що таке package.json
package.json — це головний файл конфігурації JavaScript-проєкту. У ньому зберігаються назва проєкту, залежності, скрипти та інші налаштування.
{
"name": "my-project",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"start": "next start"
},
"dependencies": {
"axios": "^1.0.0"
}
}
CommonJS та ES Modules
У старіших Node.js-проєктах часто зустрічається CommonJS, а в сучасних — ES Modules. У React-проєктах ти найчастіше будеш працювати саме з import/export.
// CommonJS
const axios = require("axios");
module.exports = {
axios
};
// ES Modules
import axios from "axios";
export { axios };
Базовий приклад Node.js
const userName = "Viktor";
function sum(a, b) {
return a + b;
}
console.log("Hello from Node.js");
console.log("User:", userName);
console.log("2 + 3 =", sum(2, 3));
Корисні команди Node.js для фронтендера
node index.js
npm init -y
npm install axios
npm install express
npm run dev
npm run build
Express — мінімальний backend для фронтендера
Express — це легкий web-фреймворк для Node.js. Його часто використовують для створення простих API, які потім викликає фронтенд через fetch або Axios.
Що важливо знати фронтендеру про Express
- Express дозволяє швидко створювати маршрути API.
- Маршрути можуть обробляти GET, POST, PUT, PATCH, DELETE.
- Через middleware можна парсити JSON, логувати запити, перевіряти токени.
- Якщо фронтенд і бекенд працюють на різних доменах або портах, часто потрібен CORS.
- Фронтендеру важливо розуміти структуру відповіді API та статус-коди.
Встановлення Express
npm init -y
npm install express
npm install cors
Базовий сервер Express
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());
const users = [
{ id: 1, name: "Anna" },
{ id: 2, name: "Ivan" }
];
app.get("/", function (req, res) {
res.send("API is running");
});
app.get("/users", function (req, res) {
res.json(users);
});
app.get("/users/:id", function (req, res) {
const userId = Number(req.params.id);
const user = users.find(function (item) {
return item.id === userId;
});
if (!user) {
return res.status(404).json({ message: "User not found" });
}
res.json(user);
});
app.post("/users", function (req, res) {
const newUser = {
id: Date.now(),
name: req.body.name
};
users.push(newUser);
res.status(201).json(newUser);
});
app.listen(5000, function () {
console.log("Server started on port 5000");
});
Що треба розуміти про HTTP
- GET — отримати дані.
- POST — створити нові дані.
- PUT — повністю оновити дані.
- PATCH — частково оновити дані.
- DELETE — видалити дані.
Найважливіші статус-коди
- 200 — успішний запит.
- 201 — успішне створення ресурсу.
- 400 — некоректний запит.
- 401 — користувач не авторизований.
- 403 — доступ заборонений.
- 404 — ресурс не знайдено.
- 500 — помилка на сервері.
Axios — повний гайд для trainee
Axios — це HTTP-клієнт для браузера та Node.js. Він використовується для виконання запитів до API: отримання списків, відправки форм, оновлення даних, видалення записів, додавання токена авторизації та централізованої обробки помилок.
Чому часто використовують Axios, а не fetch
- Зручніший синтаксис для конфігурації запиту.
- Є готовий інстанс з baseURL та headers.
- Є інтерсептори для токенів та глобальної обробки помилок.
- Зручніше працювати з params, timeout, multipart/form-data.
- Однаковий підхід у браузері та Node.js.
Встановлення Axios
npm install axios
Перший імпорт
import axios from "axios";
Базовий GET-запит через then/catch
import axios from "axios";
axios
.get("https://jsonplaceholder.typicode.com/posts")
.then(function (response) {
console.log("Data:", response.data);
console.log("Status:", response.status);
})
.catch(function (error) {
console.error("Request error:", error);
});
Базовий GET-запит через async/await
import axios from "axios";
async function fetchPosts() {
try {
const response = await axios.get("https://jsonplaceholder.typicode.com/posts");
console.log("Data:", response.data);
console.log("Status:", response.status);
console.log("Headers:", response.headers);
} catch (error) {
console.error("Request error:", error);
}
}
fetchPosts();
Що повертає Axios у response
У більшості випадків тобі найчастіше потрібне саме response.data. Але корисно знати і всю структуру відповіді.
const response = await axios.get("/users");
console.log(response.data);
console.log(response.status);
console.log(response.statusText);
console.log(response.headers);
console.log(response.config);
GET-запит з параметрами
Якщо потрібно передати query parameters, краще робити це через властивість params, а не складати URL вручну.
import axios from "axios";
async function fetchUserPosts() {
try {
const response = await axios.get("https://jsonplaceholder.typicode.com/posts", {
params: {
userId: 1
}
});
console.log(response.data);
} catch (error) {
console.error(error);
}
}
POST-запит
import axios from "axios";
async function createPost() {
try {
const response = await axios.post("https://jsonplaceholder.typicode.com/posts", {
title: "New post",
body: "Post content",
userId: 1
});
console.log("Created:", response.data);
} catch (error) {
console.error(error);
}
}
PUT, PATCH, DELETE
import axios from "axios";
async function updatePost() {
const putResponse = await axios.put("https://jsonplaceholder.typicode.com/posts/1", {
id: 1,
title: "Updated title",
body: "Updated content",
userId: 1
});
console.log("PUT:", putResponse.data);
const patchResponse = await axios.patch("https://jsonplaceholder.typicode.com/posts/1", {
title: "Patched title"
});
console.log("PATCH:", patchResponse.data);
const deleteResponse = await axios.delete("https://jsonplaceholder.typicode.com/posts/1");
console.log("DELETE status:", deleteResponse.status);
}
Axios через загальний конфіг-об’єкт
Axios можна викликати не тільки через axios.get або axios.post, а і через універсальний синтаксис axios(config).
import axios from "axios";
async function fetchUsers() {
const response = await axios({
method: "get",
url: "/users",
baseURL: "https://jsonplaceholder.typicode.com",
headers: {
Accept: "application/json"
},
params: {
_limit: 5
},
timeout: 5000
});
console.log(response.data);
}
Найважливіші поля конфігурації Axios
- method — HTTP-метод запиту.
- url — endpoint.
- baseURL — базова адреса API.
- headers — заголовки запиту.
- params — query parameters.
- data — тіло запиту для POST, PUT, PATCH.
- timeout — максимальний час очікування.
- signal — скасування запиту.
Створення Axios instance
Один із найважливіших практичних патернів. Замість того щоб у кожному файлі повторювати baseURL, timeout і headers, створюють окремий інстанс.
import axios from "axios";
export const api = axios.create({
baseURL: "https://api.example.com",
timeout: 10000,
headers: {
"Content-Type": "application/json"
}
});
Навіщо потрібен instance
- Не треба дублювати baseURL у кожному запиті.
- Легше змінити headers в одному місці.
- Зручно підключати інтерсептори.
- Сервіси стають коротшими і чистішими.
Сервісний шар поверх Axios instance
Хороша практика — не писати axios прямо всередині кожного React-компонента, а винести запити в окремі service-файли.
// src/api/axios.js
import axios from "axios";
export const api = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
timeout: 10000
});
// src/services/postService.js
import { api } from "../api/axios";
export async function getPosts() {
const response = await api.get("/posts");
return response.data;
}
export async function getPostById(id) {
const response = await api.get("/posts/" + id);
return response.data;
}
export async function createPost(payload) {
const response = await api.post("/posts", payload);
return response.data;
}
Інтерсептори запиту
Request interceptor дозволяє змінити конфігурацію перед відправкою запиту. Найчастіше так додають токен авторизації.
import axios from "axios";
export const api = axios.create({
baseURL: "https://api.example.com"
});
api.interceptors.request.use(
function (config) {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = "Bearer " + token;
}
return config;
},
function (error) {
return Promise.reject(error);
}
);
Інтерсептори відповіді
Response interceptor дозволяє централізовано обробляти 401, 403, 500 та інші помилки.
api.interceptors.response.use(
function (response) {
return response;
},
function (error) {
if (error.response && error.response.status === 401) {
console.log("Unauthorized user");
}
if (error.response && error.response.status === 500) {
console.log("Server error");
}
return Promise.reject(error);
}
);
Обробка помилок в Axios
Помилка в Axios може бути різною: сервер відповів з помилкою, сервер не відповів взагалі, або сталася помилка в конфігурації запиту.
import axios from "axios";
async function fetchData() {
try {
const response = await axios.get("https://api.example.com/users");
return response.data;
} catch (error) {
if (error.response) {
console.log("Server responded with error");
console.log("Status:", error.response.status);
console.log("Data:", error.response.data);
} else if (error.request) {
console.log("Request was sent, but no response received");
} else {
console.log("Request config error:", error.message);
}
throw error;
}
}
Timeout у Axios
const response = await axios.get("https://api.example.com/users", {
timeout: 3000
});
Якщо сервер довго не відповідає, Axios перерве запит після вказаного часу.
Скасування запиту через AbortController
Це корисно, коли компонент розмонтовується, а запит ще виконується.
import axios from "axios";
const controller = new AbortController();
axios
.get("https://jsonplaceholder.typicode.com/posts", {
signal: controller.signal
})
.then(function (response) {
console.log(response.data);
})
.catch(function (error) {
console.error(error);
});
controller.abort();
Відправка заголовків
const response = await axios.get("https://api.example.com/profile", {
headers: {
Authorization: "Bearer your-token",
Accept: "application/json"
}
});
Відправка форми через FormData
import axios from "axios";
async function uploadAvatar(file) {
const formData = new FormData();
formData.append("avatar", file);
formData.append("userId", "15");
const response = await axios.post("https://api.example.com/upload", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
});
return response.data;
}
Кілька запитів одночасно
import axios from "axios";
async function fetchDashboardData() {
try {
const results = await Promise.all([
axios.get("https://jsonplaceholder.typicode.com/posts"),
axios.get("https://jsonplaceholder.typicode.com/users"),
axios.get("https://jsonplaceholder.typicode.com/comments")
]);
const posts = results[0].data;
const users = results[1].data;
const comments = results[2].data;
console.log(posts, users, comments);
} catch (error) {
console.error(error);
}
}
Axios у звичайному React-компоненті
import { useEffect, useState } from "react";
import axios from "axios";
export default function PostsList() {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(function () {
async function loadPosts() {
try {
setIsLoading(true);
setErrorMessage("");
const response = await axios.get("https://jsonplaceholder.typicode.com/posts");
setPosts(response.data);
} catch (error) {
setErrorMessage("Failed to load posts");
} finally {
setIsLoading(false);
}
}
loadPosts();
}, []);
if (isLoading) {
return <p>Loading...</p>;
}
if (errorMessage) {
return <p>{errorMessage}</p>;
}
return (
<ul>
{posts.slice(0, 10).map(function (post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
);
}
Кращий варіант для React — через service
// src/services/postService.js
import axios from "axios";
export async function getPosts() {
const response = await axios.get("https://jsonplaceholder.typicode.com/posts");
return response.data;
}
// src/components/PostsList.jsx
import { useEffect, useState } from "react";
import { getPosts } from "../services/postService";
export default function PostsList() {
const [posts, setPosts] = useState([]);
useEffect(function () {
async function loadPosts() {
const data = await getPosts();
setPosts(data);
}
loadPosts();
}, []);
return (
<ul>
{posts.slice(0, 10).map(function (post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
);
}
Axios + Vite
У Vite використання Axios дуже прямолінійне. Ти створюєш React-проєкт, встановлюєш axios, створюєш окремий інстанс і окремі service-файли.
Створення проєкту Vite
npm create vite@latest my-app
cd my-app
npm install
npm install axios
npm run dev
Рекомендована структура у Vite
src/
api/
axios.js
services/
userService.js
postService.js
components/
PostsList.jsx
pages/
App.jsx
Axios instance у Vite
// src/api/axios.js
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json"
}
});
.env у Vite
VITE_API_URL=https://api.example.com
У Vite змінні середовища для клієнтського коду повинні починатися з префікса VITE_.
Сервіс у Vite
// src/services/userService.js
import { api } from "../api/axios";
export async function getUsers() {
const response = await api.get("/users");
return response.data;
}
Використання у компоненті Vite
import { useEffect, useState } from "react";
import { getUsers } from "./services/userService";
export default function App() {
const [users, setUsers] = useState([]);
useEffect(function () {
async function loadUsers() {
const data = await getUsers();
setUsers(data);
}
loadUsers();
}, []);
return (
<main>
<h1>Users</h1>
<ul>
{users.map(function (user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
</main>
);
}
Axios + Next.js
У Next.js Axios теж можна використовувати без проблем, але важливо розуміти, де саме виконується код: на сервері чи в браузері.
Що важливо розуміти в Next.js
- Server Components виконуються на сервері.
- Client Components виконуються у браузері.
- У App Router можна робити async-запити прямо в Server Component.
- У Client Component запити роблять через useEffect або інші клієнтські патерни.
- Для публічних змінних середовища використовується префікс NEXT_PUBLIC_.
Встановлення в Next.js
npm install axios
Рекомендована структура в Next.js
app/
users/
page.jsx
components/
UsersClient.jsx
lib/
axios.js
services/
userService.js
Axios instance у Next.js
// lib/axios.js
import axios from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json"
}
});
.env.local у Next.js
NEXT_PUBLIC_API_URL=https://api.example.com
Сервіс у Next.js
// services/userService.js
import { api } from "../lib/axios";
export async function getUsers() {
const response = await api.get("/users");
return response.data;
}
export async function getUserById(id) {
const response = await api.get("/users/" + id);
return response.data;
}
App Router: використання в Server Component
Це зручно, коли потрібно отримати дані на сервері до рендеру сторінки.
// app/users/page.jsx
import { getUsers } from "@/services/userService";
export default async function UsersPage() {
const users = await getUsers();
return (
<section>
<h1>Users</h1>
<ul>
{users.map(function (user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
</section>
);
}
App Router: використання в Client Component
Якщо потрібні useState, useEffect, кліки, інпути або запит після дії користувача — це вже Client Component.
"use client";
import { useEffect, useState } from "react";
import { getUsers } from "@/services/userService";
export default function UsersClient() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(function () {
async function loadUsers() {
try {
setIsLoading(true);
const data = await getUsers();
setUsers(data);
} finally {
setIsLoading(false);
}
}
loadUsers();
}, []);
if (isLoading) {
return <p>Loading...</p>;
}
return (
<ul>
{users.map(function (user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Pages Router: приклад через getServerSideProps
Якщо у проєкті використовується не App Router, а старіший Pages Router, запит можна робити в getServerSideProps.
// pages/users.jsx
import axios from "axios";
export async function getServerSideProps() {
const response = await axios.get("https://jsonplaceholder.typicode.com/users");
return {
props: {
users: response.data
}
};
}
export default function UsersPage(props) {
return (
<section>
<h1>Users</h1>
<ul>
{props.users.map(function (user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
</section>
);
}
Головна різниця між Vite та Next.js при використанні Axios
- У Vite код React-компонентів зазвичай виконується у браузері.
- У Next.js частина коду може виконуватися на сервері.
- У Vite змінні середовища мають префікс VITE_.
- У Next.js публічні змінні середовища мають префікс NEXT_PUBLIC_.
- У Next.js треба уважно розрізняти Server Components і Client Components.
Практична організація коду з Axios
Найкраще мислити не окремими запитами, а шарами застосунку.
- api/axios.js — базовий інстанс.
- services/ — конкретні функції для роботи з API.
- components/ або pages/ — виклик сервісів і рендер UI.
Приклад повної схеми
// src/api/axios.js
import axios from "axios";
export const api = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
timeout: 10000
});
// src/services/todoService.js
import { api } from "../api/axios";
export async function getTodos() {
const response = await api.get("/todos");
return response.data;
}
// src/components/TodoList.jsx
import { useEffect, useState } from "react";
import { getTodos } from "../services/todoService";
export default function TodoList() {
const [todos, setTodos] = useState([]);
useEffect(function () {
async function loadTodos() {
const data = await getTodos();
setTodos(data.slice(0, 10));
}
loadTodos();
}, []);
return (
<ul>
{todos.map(function (todo) {
return (
<li key={todo.id}>
{todo.title} - {todo.completed ? "done" : "not done"}
</li>
);
})}
</ul>
);
}
Типові помилки при роботі з Axios
- Писати axios-запити прямо в кожному компоненті без service-шару.
- Дублювати baseURL у багатьох файлах.
- Не обробляти помилки через try/catch.
- Не перевіряти error.response перед доступом до status.
- Зберігати токен, але не додавати його в headers.
- Плутати params і data.
- Забувати про скасування запиту в компонентах з useEffect.
- Змішувати серверну і клієнтську логіку в Next.js.
Поширена помилка: params замість data і навпаки
// Неправильно для POST
axios.post("/users", {
params: {
name: "Anna"
}
});
// Правильно для POST
axios.post("/users", {
name: "Anna"
});
// Правильно для GET з query parameters
axios.get("/users", {
params: {
role: "admin"
}
});
Поширена помилка: без try/catch
// Не дуже добре
const response = await axios.get("/users");
setUsers(response.data);
// Краще
try {
const response = await axios.get("/users");
setUsers(response.data);
} catch (error) {
console.error(error);
}
Що варто запам’ятати
- Node.js потрібен фронтендеру як база для інструментів, скриптів і серверного JS.
- Express — це простий спосіб створити API, з яким працює фронтенд.
- Axios — це зручний HTTP-клієнт для GET, POST, PUT, PATCH, DELETE та роботи з API.
- У реальних проєктах краще створювати Axios instance і service-шар.
- У Vite змінні середовища мають префікс VITE_.
- У Next.js публічні змінні середовища мають префікс NEXT_PUBLIC_.
- У Next.js важливо розуміти різницю між Server Components і Client Components.
Міні-шпаргалка по командах
npm init -y
npm install axios
npm install express
npm install cors
npm create vite@latest my-app
npm install
npm run dev
npx create-next-app@latest my-next-app
npm install axios
npm run dev
Міні-шпаргалка по Axios
import axios from "axios";
axios.get("/users");
axios.get("/users", { params: { page: 1 } });
axios.post("/users", { name: "Anna" });
axios.put("/users/1", { name: "Updated Anna" });
axios.patch("/users/1", { name: "Patched Anna" });
axios.delete("/users/1");
const api = axios.create({
baseURL: "https://api.example.com"
});
api.interceptors.request.use(function (config) {
return config;
});
api.interceptors.response.use(
function (response) {
return response;
},
function (error) {
return Promise.reject(error);
}
);
Практичне завдання: Робота з Axios та Next.js (JSONPlaceholder)
Мета — реалізувати універсальний data-fetching механізм для різних типів ресурсів, використовуючи axios у Next.js застосунку.
Вимоги до завдання
- Використати axios для HTTP-запитів
- Створити окремий сервісний файл
- Підтримати кілька типів ресурсів (users, posts, comments, albums, photos)
- Реалізувати перемикання між типами
- Реалізувати навігацію Prev / Next
- Заблокувати кнопку Next, якщо ресурс не існує
Архітектурні вимоги
- UI не повинен містити логіку fetch
- Fetch повинен бути винесений у services/
- Компонент має працювати універсально для різних структур даних
Базова структура сервісу
import axios from "axios";
const baseUrl = "https://jsonplaceholder.typicode.com";
export async function getData(id, type) {
try {
const response = await axios.get(
`${baseUrl}/${type}/${id}`
);
return response.data;
} catch (error) {
return null;
}
}
React Router та роутинг у Next.js — повний гайд
Роутинг — це механізм, який пов’язує URL адреси з конкретними сторінками, компонентами та логікою навігації у застосунку.
У звичайному React-проєкті на Vite найчастіше використовують React Router. У Next.js окремий React Router зазвичай не потрібен, тому що Next.js має власну вбудовану систему маршрутизації.
Тому цю тему важливо розділяти на три частини: React Router для React/Vite, App Router для сучасного Next.js і Pages Router для старішого, але все ще поширеного підходу в Next.js.
Що саме треба розуміти фронтендеру
- Що таке клієнтський роутинг і чим він відрізняється від серверного
- Як URL пов’язується з компонентом або сторінкою
- Як працюють вкладені маршрути
- Як працюють динамічні параметри в URL
- Як читати query params
- Як робити навігацію через посилання та програмно
- Як створювати layout для груп сторінок
- Як робити 404 сторінки та захист маршрутів
- Чому React Router не є стандартним рішенням для Next.js
1. Що таке роутинг простими словами
Коли користувач переходить на /about,
застосунок повинен показати сторінку "About". Коли
переходить на /products/15, треба показати
конкретний товар з id або slug.
У класичному багатосторінковому сайті кожен перехід часто означає повне перезавантаження сторінки. У SPA-підході React Router змінює URL і перемальовує тільки потрібну частину інтерфейсу без повного reload.
У Next.js ситуація інша: там маршрути створюються файловою
системою, а не через ручне описання всіх маршрутів у
Routes.
2. React Router для звичайного React / Vite
Це типовий варіант для звичайного React-проєкту, який не використовує Next.js. Ти сам описуєш маршрути і сам вирішуєш, який компонент рендерити на якому URL.
Встановлення React Router у Vite
npm create vite@latest my-app
cd my-app
npm install
npm install react-router
Базове підключення BrowserRouter
Найпростіший старт: обгорнути застосунок у
BrowserRouter. Саме він синхронізує інтерфейс
із URL у браузері.
// main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
Перший набір маршрутів
Компонент Routes містить набір маршрутів, а
кожен Route зв’язує шлях із конкретним
компонентом.
// App.jsx
import { Routes, Route } from "react-router";
import HomePage from "./pages/HomePage";
import AboutPage from "./pages/AboutPage";
import NotFoundPage from "./pages/NotFoundPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
Приклад простих сторінок
// pages/HomePage.jsx
export default function HomePage() {
return (
<section>
<h1>Головна сторінка</h1>
<p>Тут може бути стартовий контент застосунку.</p>
</section>
);
}
// pages/AboutPage.jsx
export default function AboutPage() {
return (
<section>
<h1>Про нас</h1>
<p>Це сторінка з описом проєкту.</p>
</section>
);
}
// pages/NotFoundPage.jsx
export default function NotFoundPage() {
return (
<section>
<h1>404</h1>
<p>Такої сторінки не існує.</p>
</section>
);
}
Навігація через Link
Для переходів усередині SPA потрібно використовувати не
звичайний
<a>, а Link. Інакше браузер
буде робити повне перезавантаження сторінки.
// components/Header.jsx
import { Link } from "react-router";
export default function Header() {
return (
<nav>
<Link to="/">Головна</Link>
{" | "}
<Link to="/about">Про нас</Link>
{" | "}
<Link to="/contacts">Контакти</Link>
</nav>
);
}
Активне посилання через NavLink
NavLink зручний тоді, коли потрібно підсвітити
поточний активний маршрут у меню.
// components/MainNav.jsx
import { NavLink } from "react-router";
export default function MainNav() {
return (
<nav>
<NavLink
to="/"
end
className={function({ isActive }) {
return isActive ? "active-link" : "link";
}}
>
Головна
</NavLink>
<NavLink
to="/about"
className={function({ isActive }) {
return isActive ? "active-link" : "link";
}}
>
Про нас
</NavLink>
</nav>
);
}
Вкладені маршрути та Outlet
Коли є спільна оболонка сторінок, наприклад кабінет
користувача з боковим меню, зручно використовувати вкладені
маршрути. Для рендера дочірнього маршруту потрібен
Outlet.
// App.jsx
import { Routes, Route } from "react-router";
import DashboardLayout from "./pages/dashboard/DashboardLayout";
import DashboardHome from "./pages/dashboard/DashboardHome";
import DashboardSettings from "./pages/dashboard/DashboardSettings";
export default function App() {
return (
<Routes>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} />
<Route path="settings" element={<DashboardSettings />} />
</Route>
</Routes>
);
}
// pages/dashboard/DashboardLayout.jsx
import { Link, Outlet } from "react-router";
export default function DashboardLayout() {
return (
<section>
<h1>Dashboard</h1>
<nav>
<Link to="/dashboard">Огляд</Link>
{" | "}
<Link to="/dashboard/settings">Налаштування</Link>
</nav>
<hr />
<Outlet />
</section>
);
}
// pages/dashboard/DashboardHome.jsx
export default function DashboardHome() {
return <p>Головна сторінка кабінету.</p>;
}
// pages/dashboard/DashboardSettings.jsx
export default function DashboardSettings() {
return <p>Сторінка налаштувань кабінету.</p>;
}
Динамічні параметри через useParams
Якщо шлях містить змінну частину, наприклад
/posts/:postId, її можна отримати через
useParams.
// App.jsx
import { Routes, Route } from "react-router";
import PostPage from "./pages/PostPage";
export default function App() {
return (
<Routes>
<Route path="/posts/:postId" element={<PostPage />} />
</Routes>
);
}
// pages/PostPage.jsx
import { useParams } from "react-router";
export default function PostPage() {
const params = useParams();
return (
<section>
<h1>Пост</h1>
<p>ID поста: {params.postId}</p>
</section>
);
}
Query params через useSearchParams
Якщо у тебе URL виду
/products?category=laptops&page=2,
параметри запиту можна читати через
useSearchParams.
// pages/ProductsPage.jsx
import { useSearchParams } from "react-router";
export default function ProductsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") || "all";
const page = searchParams.get("page") || "1";
function handleOpenPhones() {
setSearchParams({
category: "phones",
page: "1",
});
}
return (
<section>
<h1>Products</h1>
<p>Категорія: {category}</p>
<p>Сторінка: {page}</p>
<button type="button" onClick={handleOpenPhones}>
Відкрити телефони
</button>
</section>
);
}
Програмна навігація через useNavigate
Іноді посилання недостатньо. Наприклад, після відправки форми треба автоматично перекинути користувача на іншу сторінку.
// pages/LoginPage.jsx
import { useNavigate } from "react-router";
export default function LoginPage() {
const navigate = useNavigate();
function handleLogin() {
const isSuccess = true;
if (isSuccess) {
navigate("/dashboard");
}
}
return (
<section>
<h1>Login</h1>
<button type="button" onClick={handleLogin}>
Увійти
</button>
</section>
);
}
Navigate для редіректу
Якщо треба не по кліку, а під час рендера перекинути
користувача на інший маршрут, можна використати
Navigate.
// pages/PrivatePage.jsx
import { Navigate } from "react-router";
export default function PrivatePage() {
const isAuthorized = false;
if (!isAuthorized) {
return <Navigate to="/login" replace />;
}
return <h1>Приватна сторінка</h1>;
}
Базовий приклад захисту маршруту
// components/ProtectedRoute.jsx
import { Navigate } from "react-router";
export default function ProtectedRoute({ isAuthorized, children }) {
if (!isAuthorized) {
return <Navigate to="/login" replace />;
}
return children;
}
// App.jsx
import { Routes, Route } from "react-router";
import ProtectedRoute from "./components/ProtectedRoute";
import DashboardPage from "./pages/DashboardPage";
import LoginPage from "./pages/LoginPage";
export default function App() {
const isAuthorized = true;
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute isAuthorized={isAuthorized}>
<DashboardPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
Приклад структури папок для React Router
src/
main.jsx
App.jsx
components/
Header.jsx
MainNav.jsx
ProtectedRoute.jsx
pages/
HomePage.jsx
AboutPage.jsx
LoginPage.jsx
DashboardPage.jsx
PostPage.jsx
ProductsPage.jsx
NotFoundPage.jsx
dashboard/
DashboardLayout.jsx
DashboardHome.jsx
DashboardSettings.jsx
Типові помилки в React Router
-
Використовують
<a>замістьLink -
Забувають додати
Outletу layout-компонент - Плутають абсолютні та вкладені шляхи
- Не ставлять маршрут
*для 404 - Пробують використовувати React Router усередині Next.js без потреби
3. Чи треба React Router у Next.js
У більшості випадків — ні. Next.js вже має власний вбудований роутер.
Якщо ти працюєш із Next.js, не треба окремо ставити React Router тільки для того, щоб робити сторінки, динамічні маршрути чи навігацію. Усе це вже є в самому Next.js.
Тому правильне питання звучить не "як використовувати React Router у Next.js", а "який роутер Next.js використовується в моєму проєкті: App Router чи Pages Router".
4. Next.js App Router — сучасний підхід
App Router — це сучасна система маршрутизації Next.js. Вона
базується на директорії app і спеціальних
файлах page.js, layout.js,
loading.js, error.js,
not-found.js та інших.
Головна ідея App Router
-
Маршрути створюються папками та файлами в директорії
app page.jsxстворює конкретну сторінку-
layout.jsxстворює спільну оболонку для сторінок - Вкладені папки створюють вкладені URL
-
Динамічні маршрути створюються через
[slug] -
Для програмної навігації в клієнтських компонентах
використовується
useRouterзnext/navigation
Базова структура App Router
app/
layout.jsx
page.jsx
about/
page.jsx
blog/
page.jsx
[slug]/
page.jsx
dashboard/
layout.jsx
page.jsx
settings/
page.jsx
loading.jsx
not-found.jsx
Кореневий layout
Root layout є обов’язковим. Саме тут зазвичай лежить загальна структура документа, header, footer, провайдери та глобальна оболонка.
// app/layout.jsx
export const metadata = {
title: "My Next App",
description: "Навчальний приклад App Router",
};
export default function RootLayout({ children }) {
return (
<html lang="uk">
<body>
<header>
<h1>Мій Next.js застосунок</h1>
</header>
<main>{children}</main>
<footer>
<p>Підвал сайту</p>
</footer>
</body>
</html>
);
}
Головна сторінка App Router
Файл app/page.jsx відповідає за маршрут
/.
// app/page.jsx
export default function HomePage() {
return (
<section>
<h2>Головна сторінка</h2>
<p>Це маршрут / у Next.js App Router.</p>
</section>
);
}
Звичайна сторінка в App Router
Якщо створити app/about/page.jsx, отримаємо
маршрут /about.
// app/about/page.jsx
export default function AboutPage() {
return (
<section>
<h2>About</h2>
<p>Це сторінка /about.</p>
</section>
);
}
Навігація через Link в App Router
Для переходів між сторінками у Next.js використовують
next/link.
// components/Header.jsx
import Link from "next/link";
export default function Header() {
return (
<nav>
<Link href="/">Головна</Link>
{" | "}
<Link href="/about">Про нас</Link>
{" | "}
<Link href="/blog">Блог</Link>
</nav>
);
}
Вкладені маршрути та вкладені layout у App Router
Якщо зробити папку dashboard, це буде сегмент
URL /dashboard. Якщо всередині неї створити
свій layout.jsx, він стане локальною оболонкою
тільки для цього розділу.
// app/dashboard/layout.jsx
import Link from "next/link";
export default function DashboardLayout({ children }) {
return (
<section>
<h2>Dashboard Layout</h2>
<nav>
<Link href="/dashboard">Огляд</Link>
{" | "}
<Link href="/dashboard/settings">Налаштування</Link>
</nav>
<hr />
{children}
</section>
);
}
// app/dashboard/page.jsx
export default function DashboardPage() {
return <p>Це сторінка /dashboard.</p>;
}
// app/dashboard/settings/page.jsx
export default function DashboardSettingsPage() {
return <p>Це сторінка /dashboard/settings.</p>;
}
Динамічні маршрути в App Router
Динамічний сегмент створюється через квадратні дужки.
Наприклад, папка
[slug] означає, що частина URL буде змінною.
// app/blog/[slug]/page.jsx
export default async function BlogPostPage({ params }) {
const resolvedParams = await params;
const slug = resolvedParams.slug;
return (
<article>
<h1>Сторінка поста</h1>
<p>Поточний slug: {slug}</p>
</article>
);
}
Пошукові параметри в App Router
Якщо сторінка відкривається як
/catalog?category=books&page=2, search
params можна отримати через пропс
searchParams у сторінці.
// app/catalog/page.jsx
export default function CatalogPage({ searchParams }) {
const category = searchParams.category || "all";
const page = searchParams.page || "1";
return (
<section>
<h1>Каталог</h1>
<p>Категорія: {category}</p>
<p>Сторінка: {page}</p>
</section>
);
}
Програмна навігація через useRouter в App Router
В App Router хук імпортується з
next/navigation, а не з
next/router.
// app/login/LoginButton.jsx
"use client";
import { useRouter } from "next/navigation";
export default function LoginButton() {
const router = useRouter();
function handleSuccessLogin() {
router.push("/dashboard");
}
function handleReplace() {
router.replace("/dashboard");
}
function handleRefresh() {
router.refresh();
}
return (
<div>
<button type="button" onClick={handleSuccessLogin}>
Перейти в dashboard
</button>
<button type="button" onClick={handleReplace}>
Замінити поточний маршрут
</button>
<button type="button" onClick={handleRefresh}>
Оновити поточний маршрут
</button>
</div>
);
}
Active link в App Router
Готового NavLink, як у React Router, тут немає.
Зазвичай активний пункт меню визначають через
usePathname.
// components/AppNav.jsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function AppNav() {
const pathname = usePathname();
function getClassName(path) {
return pathname === path ? "active-link" : "link";
}
return (
<nav>
<Link href="/" className={getClassName("/")}>
Головна
</Link>
{" | "}
<Link href="/about" className={getClassName("/about")}>
Про нас
</Link>
{" | "}
<Link href="/dashboard" className={getClassName("/dashboard")}>
Dashboard
</Link>
</nav>
);
}
loading.jsx у App Router
Це спеціальний файл для відображення стану завантаження сегмента маршруту.
// app/dashboard/loading.jsx
export default function DashboardLoading() {
return <p>Завантаження dashboard...</p>;
}
not-found.jsx у App Router
Це спеціальний файл для сторінки 404 або локальної not found обробки.
// app/not-found.jsx
import Link from "next/link";
export default function NotFoundPage() {
return (
<section>
<h1>404</h1>
<p>Сторінку не знайдено.</p>
<Link href="/">Повернутися на головну</Link>
</section>
);
}
Захист маршруту в App Router
У Next.js захист маршрутів часто роблять не через окремий Route-компонент, як у React Router, а через серверні перевірки, middleware або редірект усередині сторінки чи layout.
// app/dashboard/page.jsx
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const isAuthorized = false;
if (!isAuthorized) {
redirect("/login");
}
return <h1>Приватний dashboard</h1>;
}
Коли в App Router потрібен "use client"
За замовчуванням сторінки та компоненти в App Router можуть
бути серверними. Але якщо ти використовуєш обробники подій,
useState, useEffect,
useRouter, usePathname
чи інші клієнтські хуки, треба додати на початку файлу
"use client";.
// components/Counter.jsx
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Лічильник: {count}</p>
<button type="button" onClick={function() {
setCount(count + 1);
}}>
Збільшити
</button>
</div>
);
}
Типові помилки в App Router
-
Імпортують
useRouterзnext/routerзамістьnext/navigation -
Забувають
"use client"у клієнтських компонентах - Намагаються описувати маршрути вручну, як у React Router
-
Плутають
paramsіsearchParams - Ставлять React Router у Next.js без реальної потреби
5. Next.js Pages Router — класичний підхід
Pages Router — це старіша система маршрутизації Next.js. Вона все ще часто зустрічається в реальних проєктах, тому її треба розуміти.
Тут маршрути створюються папками і файлами в директорії
pages.
Базова структура Pages Router
pages/
index.jsx
about.jsx
blog/
index.jsx
[slug].jsx
dashboard/
index.jsx
settings.jsx
404.jsx
_app.jsx
Головна сторінка в Pages Router
Файл pages/index.jsx відповідає за маршрут
/.
// pages/index.jsx
export default function HomePage() {
return (
<section>
<h1>Home</h1>
<p>Це головна сторінка Pages Router.</p>
</section>
);
}
Звичайна сторінка в Pages Router
// pages/about.jsx
export default function AboutPage() {
return (
<section>
<h1>About</h1>
<p>Це сторінка /about у Pages Router.</p>
</section>
);
}
Спільна оболонка через _app.jsx
У Pages Router глобальну оболонку часто підключають через
pages/_app.jsx.
// pages/_app.jsx
import "../styles/globals.css";
import Header from "../components/Header";
export default function App({ Component, pageProps }) {
return (
<>
<Header />
<Component {...pageProps} />
</>
);
}
Навігація через Link у Pages Router
// components/Header.jsx
import Link from "next/link";
export default function Header() {
return (
<nav>
<Link href="/">Головна</Link>
{" | "}
<Link href="/about">Про нас</Link>
{" | "}
<Link href="/dashboard">Dashboard</Link>
</nav>
);
}
Динамічні маршрути в Pages Router
Для динамічного маршруту використовується файл з квадратними
дужками, наприклад pages/blog/[slug].jsx.
// pages/blog/[slug].jsx
import { useRouter } from "next/router";
export default function BlogPostPage() {
const router = useRouter();
const { slug } = router.query;
return (
<article>
<h1>Сторінка поста</h1>
<p>Slug: {slug}</p>
</article>
);
}
Програмна навігація через useRouter у Pages Router
Тут хук імпортується з next/router.
// pages/login.jsx
import { useRouter } from "next/router";
export default function LoginPage() {
const router = useRouter();
function handleLogin() {
router.push("/dashboard");
}
function handleReplace() {
router.replace("/dashboard");
}
return (
<section>
<h1>Login</h1>
<button type="button" onClick={handleLogin}>
Увійти
</button>
<button type="button" onClick={handleReplace}>
Замінити маршрут
</button>
</section>
);
}
Передача query у router.push
// pages/posts/index.jsx
import { useRouter } from "next/router";
export default function PostsPage() {
const router = useRouter();
function openPost() {
router.push({
pathname: "/blog/[slug]",
query: {
slug: "my-first-post",
},
});
}
return (
<section>
<h1>Posts</h1>
<button type="button" onClick={openPost}>
Відкрити пост
</button>
</section>
);
}
404 сторінка в Pages Router
// pages/404.jsx
import Link from "next/link";
export default function NotFoundPage() {
return (
<section>
<h1>404</h1>
<p>Сторінку не знайдено.</p>
<Link href="/">Повернутися на головну</Link>
</section>
);
}
Простий захист сторінки в Pages Router
// pages/dashboard/index.jsx
import { useEffect } from "react";
import { useRouter } from "next/router";
export default function DashboardPage() {
const router = useRouter();
const isAuthorized = false;
useEffect(function() {
if (!isAuthorized) {
router.push("/login");
}
}, [isAuthorized, router]);
if (!isAuthorized) {
return <p>Перенаправлення...</p>;
}
return <h1>Приватний dashboard</h1>;
}
Типові помилки в Pages Router
-
Плутають
pagesіappпідходи в одному поясненні - Імпортують
useRouterне з того пакета - Чекають від Pages Router поведінки layout, як у App Router
-
Не враховують, що
router.queryможе бути порожнім на ранньому етапі
6. React Router vs Next.js Router — головна різниця
-
React Router: ти вручну описуєш маршрути через
RoutesіRoute -
Next.js App Router: ти створюєш папки та спеціальні файли
в
app -
Next.js Pages Router: ти створюєш файли в
pages -
React Router має
NavLinkтаOutlet, у Next.js є свої аналоги черезLink,layoutіusePathname - React Router — типовий вибір для React/Vite, але не типовий вибір для Next.js
7. Як мислити правильно на співбесіді або в реальному проєкті
Якщо ти бачиш проєкт на Vite або звичайному React без Next.js, майже напевно роутинг буде через React Router.
Якщо ти бачиш сучасний Next.js з директорією
app, працюєш з App Router.
Якщо в проєкті головна директорія для сторінок —
pages, то це Pages Router.
Найбільша помилка початківців — намагатися перенести ментальну модель React Router один в один у Next.js. У Next.js потрібно мислити файловими сегментами, layout і вбудованими інструментами маршрутизації.
8. Швидка шпаргалка
React Router / Vite
-
BrowserRouter— обгортка для застосунку Routes— контейнер маршрутівRoute— окремий маршрутLink— перехід без перезавантаженняNavLink— посилання з active станом-
Outlet— місце рендера дочірнього маршруту useParams— параметри маршрутуuseSearchParams— query paramsuseNavigate— програмна навігаціяNavigate— редірект у JSX
Next.js App Router
app/page.jsx— сторінка сегментаapp/layout.jsx— layout сегмента[slug]— динамічний сегментnext/link— навігація-
next/navigation— useRouter, usePathname, redirect loading.jsx— loading стан сегментаnot-found.jsx— 404
Next.js Pages Router
pages/index.jsx— маршрут /pages/about.jsx— маршрут /about-
pages/blog/[slug].jsx— динамічний маршрут pages/_app.jsx— глобальна оболонкаnext/router— useRouterpages/404.jsx— сторінка 404
9. Підсумок
React Router треба добре знати для звичайного React/Vite, бо саме там ти сам будуєш всю систему маршрутів.
У Next.js потрібно не тягнути React Router за звичкою, а працювати з вбудованим роутером фреймворку.
Для сучасних Next.js-проєктів особливо важливо впевнено
розуміти App Router:
app, page, layout,
динамічні сегменти, Link,
useRouter, usePathname,
redirect, loading і
not-found.
Якщо ти це добре засвоїш, то зможеш орієнтуватися і в простих SPA на React, і в реальних Next.js-проєктах.
Практичне завдання: Багатосторінковий сайт на Next.js (App Router)
Мета: закріпити базові принципи маршрутизації у Next.js (file-based routing), динамічні маршрути, навігацію через Link та програмну навігацію.
Завдання просте, без складної логіки — лише відпрацювання базових речей.
Що потрібно створити
Невеликий сайт "Mini Blog" з такими сторінками:
- Головна сторінка
- Сторінка "Про нас"
- Список постів
- Сторінка одного поста (динамічний маршрут)
- Сторінка 404
Крок 1 — Створення проекту
npx create-next-app@latest mini-blog
cd mini-blog
npm run dev
Обери App Router (за замовчуванням у нових версіях).
Крок 2 — Структура проекту
Ти повинен отримати приблизно таку структуру:
app/
layout.js
page.js
about/
page.js
posts/
page.js
[id]/
page.js
not-found.js
Крок 3 — Головна сторінка
app/page.js
import Link from "next/link";
export default function HomePage() {
return (
<div>
<h1>Mini Blog</h1>
<nav>
<ul>
<li><Link href="/about">About</Link></li>
<li><Link href="/posts">Posts</Link></li>
</ul>
</nav>
</div>
);
}
Тут ти відпрацьовуєш:
- Link з next/link
- базову навігацію
Крок 4 — Сторінка About
app/about/page.js
export default function AboutPage() {
return (
<div>
<h1>About</h1>
<p>This is a simple training project.</p>
</div>
);
}
Тут ти відпрацьовуєш file-based routing.
Крок 5 — Список постів
app/posts/page.js
import Link from "next/link";
const posts = [
{ id: "1", title: "First Post" },
{ id: "2", title: "Second Post" },
{ id: "3", title: "Third Post" }
];
export default function PostsPage() {
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map(function(post) {
return (
<li key={post.id}>
<Link href={"/posts/" + post.id}>
{post.title}
</Link>
</li>
);
})}
</ul>
</div>
);
}
Тут ти відпрацьовуєш:
- динамічну генерацію посилань
- вкладені маршрути
Крок 6 — Динамічний маршрут
app/posts/[id]/page.js
export default function PostPage({ params }) {
return (
<div>
<h1>Post ID: {params.id}</h1>
<p>This is dynamic route example.</p>
</div>
);
}
Тут ти відпрацьовуєш:
- динамічні сегменти [id]
- отримання params
Крок 7 — Програмна навігація
Додай кнопку на сторінку поста, яка повертає назад до списку.
"use client";
import { useRouter } from "next/navigation";
export default function BackButton() {
const router = useRouter();
return (
<button onClick={() => router.push("/posts")}>
Back to Posts
</button>
);
}
Тут ти відпрацьовуєш useRouter та router.push().
Крок 8 — Сторінка 404
app/not-found.js
export default function NotFound() {
return (
<div>
<h1>404 - Page Not Found</h1>
</div>
);
}
Мінімальні вимоги
- Працюють усі переходи через Link
- Працює динамічний маршрут /posts/1
- Працює кнопка програмної навігації
- Є сторінка 404
Додаткове (за бажанням)
- Винеси меню в layout.js
- Додай стилі
- Додай searchParams (?category=news)
Що ти відпрацюєш
- File-based routing
- Динамічні маршрути
- Навігацію через Link
- Програмну навігацію
- Структуру App Router
Головна ідея
Це просте тренування для розуміння того, що в Next.js маршрути створюються через структуру папок, а не через react-router-dom.
Твоя задача — не ускладнювати, а зробити акуратну, чисту структуру без зайвої логіки.
Redux Toolkit. Extending Redux — повний гайд для React та Next.js
Redux Toolkit (RTK) — це офіційний рекомендований спосіб писати Redux. Він спрощує створення store, reducer, action creator, асинхронної логіки та роботи з серверними даними.
Якщо звичайний Redux часто виглядає громіздко, то Redux Toolkit прибирає більшість шаблонного коду і дає хороші вбудовані практики “з коробки”.
Під “Extending Redux” зазвичай мають на увазі розширення стандартної Redux-логіки через middleware, async thunk, extraReducers, createListenerMiddleware, createEntityAdapter, RTK Query, кастомні селектори та модульну архітектуру store.
Що саме треба знати по темі
- Що таке store, state, reducer, action
- Для чого потрібен configureStore
- Як працює createSlice
- Як dispatch викликає action
- Що таке selector і навіщо він потрібен
- Як працює createAsyncThunk
- Що таке extraReducers
- Як розширювати Redux через middleware
- Коли використовувати createListenerMiddleware
- Для чого потрібен createEntityAdapter
- Як інтегрувати RTK з React через react-redux
- Як інтегрувати RTK з Next.js App Router
- Коли краще брати RTK Query замість ручних fetch або axios
Redux: базова ідея простими словами
Redux — це централізоване сховище стану застосунку. Замість того, щоб передавати дані через props через багато рівнів компонентів, можна зберігати їх у глобальному store.
Потік даних у Redux дуже простий: компонент dispatch-ить action, reducer отримує цей action і оновлює state, після чого компоненти отримують нові дані через selector.
Класична схема Redux
// Компонент викликає dispatch(action)
// reducer обробляє action
// store зберігає новий state
// component читає state через selector
Чому саме Redux Toolkit
Раніше в Redux треба було окремо писати типи action, action creator, switch-case reducer, підключення middleware і багато службового коду.
Redux Toolkit значно скорочує цей код і робить структуру зрозумілішою: createSlice створює reducer та action creator, configureStore налаштовує store, а createAsyncThunk допомагає з асинхронними запитами.
Що дає RTK “з коробки”
- configureStore для швидкого створення store
- createSlice для reducer + actions в одному місці
- createAsyncThunk для async логіки
- Immer для “мутабельного” синтаксису reducer
- DevTools інтеграцію
- Перевірку serializable та immutable значень у development
- RTK Query для data fetching та кешування
Встановлення
npm install @reduxjs/toolkit react-redux
Базова структура Redux Toolkit
Мінімально тобі потрібні три речі:
- slice
- store
- Provider для React
Приклад простого slice
// features/counter/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
// Immer дозволяє писати так, ніби ми мутуємо state
state.value += 1;
},
decrement(state) {
// Зовні це виглядає як мутація,
// але RTK безпечно створює новий state під капотом
state.value -= 1;
},
incrementByAmount(state, action) {
// action.payload містить дані, передані в dispatch
state.value += action.payload;
},
resetCounter(state) {
state.value = 0;
},
},
});
export const { increment, decrement, incrementByAmount, resetCounter } =
counterSlice.actions;
export default counterSlice.reducer;
Створення store
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
Підключення Provider у звичайному React
// main.jsx або index.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./app/store";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);
Використання в компоненті через hooks
// components/Counter.jsx
"use client";
import { useSelector, useDispatch } from "react-redux";
import {
increment,
decrement,
incrementByAmount,
resetCounter,
} from "../features/counter/counterSlice";
export default function Counter() {
const value = useSelector(function(state) {
return state.counter.value;
});
const dispatch = useDispatch();
return (
<section>
<h2>Counter</h2>
<p>Current value: {value}</p>
<button onClick={function() {
dispatch(increment());
}}>
Increment
</button>
<button onClick={function() {
dispatch(decrement());
}}>
Decrement
</button>
<button onClick={function() {
dispatch(incrementByAmount(5));
}}>
Increment by 5
</button>
<button onClick={function() {
dispatch(resetCounter());
}}>
Reset
</button>
</section>
);
}
Що таке slice
Slice — це шматок глобального store, який містить:
- назву
- initialState
- reducers
- автоматично згенеровані action creator
Це одна з головних ідей RTK: логіка, яка стосується одного блоку даних, зберігається в одному місці.
Що генерує createSlice
// Якщо є reducer:
addTodo(state, action) {
state.items.push(action.payload);
}
// То RTK автоматично створить:
dispatch(addTodo(payload));
// І reducer для обробки цього action
Що таке selector
Selector — це функція, яка дістає потрібні дані зі store.
Це корисно, бо компонент не повинен знати всю структуру store. Краще винести вибірку даних у окремі функції.
Прості селектори
// features/counter/counterSelectors.js
export const selectCounterValue = function(state) {
return state.counter.value;
};
export const selectIsCounterPositive = function(state) {
return state.counter.value > 0;
};
Використання selector у компоненті
// components/CounterInfo.jsx
"use client";
import { useSelector } from "react-redux";
import {
selectCounterValue,
selectIsCounterPositive,
} from "../features/counter/counterSelectors";
export default function CounterInfo() {
const value = useSelector(selectCounterValue);
const isPositive = useSelector(selectIsCounterPositive);
return (
<div>
<p>Value: {value}</p>
<p>Positive: {isPositive ? "Yes" : "No"}</p>
</div>
);
}
Асинхронність у Redux Toolkit: createAsyncThunk
Коли треба завантажити дані з API, звичайний reducer не підходить, бо reducer повинен бути синхронним. Для цього в RTK є createAsyncThunk.
Він автоматично створює три стани запиту:
- pending
- fulfilled
- rejected
Приклад async thunk
// features/posts/postsThunks.js
import { createAsyncThunk } from "@reduxjs/toolkit";
export const fetchPosts = createAsyncThunk(
"posts/fetchPosts",
async function(_, thunkAPI) {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5"
);
if (!response.ok) {
throw new Error("Failed to fetch posts");
}
const data = await response.json();
return data;
} catch (error) {
// Повертаємо контрольовану помилку в rejected.payload
return thunkAPI.rejectWithValue(error.message);
}
}
);
Slice з extraReducers для async thunk
// features/posts/postsSlice.js
import { createSlice } from "@reduxjs/toolkit";
import { fetchPosts } from "./postsThunks";
const initialState = {
items: [],
isLoading: false,
error: null,
};
const postsSlice = createSlice({
name: "posts",
initialState,
reducers: {
clearPosts(state) {
state.items = [];
state.error = null;
state.isLoading = false;
},
},
extraReducers: function(builder) {
builder
.addCase(fetchPosts.pending, function(state) {
// Позначаємо початок завантаження
state.isLoading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, function(state, action) {
// Зберігаємо отримані дані
state.isLoading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, function(state, action) {
// Зберігаємо текст помилки
state.isLoading = false;
state.error = action.payload || "Unknown error";
});
},
});
export const { clearPosts } = postsSlice.actions;
export default postsSlice.reducer;
Додаємо posts reducer у store
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import postsReducer from "../features/posts/postsSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
posts: postsReducer,
},
});
Використання async thunk у React-компоненті
// components/PostsList.jsx
"use client";
import { useDispatch, useSelector } from "react-redux";
import { fetchPosts } from "../features/posts/postsThunks";
export default function PostsList() {
const dispatch = useDispatch();
const items = useSelector(function(state) {
return state.posts.items;
});
const isLoading = useSelector(function(state) {
return state.posts.isLoading;
});
const error = useSelector(function(state) {
return state.posts.error;
});
return (
<section>
<h2>Posts</h2>
<button onClick={function() {
dispatch(fetchPosts());
}}>
Load posts
</button>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error}</p>}
<ul>
{items.map(function(post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
</section>
);
}
Що таке extraReducers
reducers обробляють action, створені всередині цього ж slice.
extraReducers потрібен, коли slice має реагувати на зовнішні action: наприклад, на createAsyncThunk, action з іншого slice або глобальні action.
Приклад взаємодії slice через extraReducers
// features/auth/authSlice.js
import { createSlice } from "@reduxjs/toolkit";
export const logoutAction = {
type: "auth/logout",
};
const authSlice = createSlice({
name: "auth",
initialState: {
user: { name: "Viktor" },
isAuth: true,
},
reducers: {
logout(state) {
state.user = null;
state.isAuth = false;
},
},
});
export const { logout } = authSlice.actions;
export default authSlice.reducer;
Інший slice реагує на logout
// features/profile/profileSlice.js
import { createSlice } from "@reduxjs/toolkit";
import { logout } from "../auth/authSlice";
const profileSlice = createSlice({
name: "profile",
initialState: {
bio: "Frontend developer",
settings: {
theme: "dark",
},
},
reducers: {},
extraReducers: function(builder) {
builder.addCase(logout, function(state) {
// Очищуємо profile state після виходу користувача
state.bio = "";
state.settings = {};
});
},
});
export default profileSlice.reducer;
Immer у Redux Toolkit
У звичайному Redux не можна напряму змінювати state. Треба повертати новий об’єкт.
У RTK використовується Immer, тому в reducer можна писати коротше:
Без Immer
// Звичайний Redux
return {
...state,
value: state.value + 1,
};
З Immer у RTK
// Redux Toolkit
state.value += 1;
Важливо: це працює лише всередині reducer, створених RTK. У звичайному JS-коді поза reducer так робити не треба.
Extending Redux: middleware
Middleware — це “посередник” між dispatch(action) і reducer. Він може:
- логувати action
- змінювати поведінку dispatch
- виконувати async логіку
- перевіряти дані
- реагувати на певні action
Простий кастомний middleware
// app/middleware/loggerMiddleware.js
export const loggerMiddleware = function(storeAPI) {
return function(next) {
return function(action) {
// Логуємо action перед обробкою
console.log("Dispatching action:", action);
const result = next(action);
// Логуємо новий state після обробки
console.log("Next state:", storeAPI.getState());
return result;
};
};
};
Підключення middleware у store
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import postsReducer from "../features/posts/postsSlice";
import { loggerMiddleware } from "./middleware/loggerMiddleware";
export const store = configureStore({
reducer: {
counter: counterReducer,
posts: postsReducer,
},
middleware: function(getDefaultMiddleware) {
return getDefaultMiddleware().concat(loggerMiddleware);
},
});
Що таке getDefaultMiddleware
configureStore автоматично додає стандартний набір middleware. Зазвичай ти не замінюєш їх повністю, а розширюєш:
middleware: function(getDefaultMiddleware) {
return getDefaultMiddleware().concat(myMiddleware);
}
Це важливо, бо разом із ними працюють корисні development-перевірки.
Serializable check та immutable check
За замовчуванням RTK перевіряє, чи не кладеш ти в state або action несеріалізовані значення, наприклад Date, Map, Set, class instance, функції, DOM-вузли.
Це добре, бо Redux state бажано тримати серіалізованим і передбачуваним.
Приклад налаштування middleware options
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
middleware: function(getDefaultMiddleware) {
return getDefaultMiddleware({
serializableCheck: false,
immutableCheck: true,
});
},
});
Так вимикати перевірки треба лише тоді, коли ти точно розумієш, навіщо це робиш. Для навчання і для більшості проєктів краще залишати стандартну поведінку.
Extending Redux: createListenerMiddleware
createListenerMiddleware — це сучасний спосіб реагувати на action або зміни state без перевантаження компонентів зайвою логікою.
Він корисний, коли треба:
- слухати певний action
- робити побічні ефекти
- ланцюжити дії після певних змін
- частково замінити складні thunk або saga сценарії
Створення listener middleware
// app/middleware/listenerMiddleware.js
import { createListenerMiddleware } from "@reduxjs/toolkit";
import { incrementByAmount } from "../../features/counter/counterSlice";
export const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
actionCreator: incrementByAmount,
effect: async function(action, listenerApi) {
// Реакція на конкретний action
console.log("incrementByAmount dispatched with:", action.payload);
// Отримання поточного state
const state = listenerApi.getState();
console.log("Current counter:", state.counter.value);
},
});
Підключення listener middleware у store
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import { listenerMiddleware } from "./middleware/listenerMiddleware";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
middleware: function(getDefaultMiddleware) {
return getDefaultMiddleware().prepend(listenerMiddleware.middleware);
},
});
Часто listener middleware додають через prepend, щоб він стояв раніше за частину інших middleware у ланцюжку.
Extending Redux: addMatcher
addMatcher дозволяє обробляти не один конкретний action, а цілу групу action за умовою.
Це зручно, коли треба однаково реагувати на багато rejected або pending станів.
Приклад addMatcher
// features/app/appSlice.js
import { createSlice } from "@reduxjs/toolkit";
import { fetchPosts } from "../posts/postsThunks";
function isRejectedAction(action) {
return action.type.endsWith("/rejected");
}
const appSlice = createSlice({
name: "app",
initialState: {
globalError: null,
},
reducers: {},
extraReducers: function(builder) {
builder
.addCase(fetchPosts.fulfilled, function(state) {
state.globalError = null;
})
.addMatcher(isRejectedAction, function(state, action) {
// Ловимо всі rejected action
state.globalError = action.payload || action.error.message;
});
},
});
export default appSlice.reducer;
Extending Redux: createEntityAdapter
Якщо ти працюєш зі списками сутностей, наприклад users, posts, comments, createEntityAdapter дуже корисний.
Він допомагає зберігати дані в нормалізованому форматі:
{
ids: [1, 2, 3],
entities: {
1: { id: 1, name: "Anna" },
2: { id: 2, name: "John" },
3: { id: 3, name: "Kate" }
}
}
Чому це зручно
- швидко оновлювати один елемент
- не дублювати елементи
- зручно будувати selector
- легко виконувати CRUD-операції
Приклад createEntityAdapter
// features/users/usersSlice.js
import {
createSlice,
createEntityAdapter,
createAsyncThunk,
} from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter({
selectId: function(user) {
return user.id;
},
sortComparer: function(a, b) {
return a.name.localeCompare(b.name);
},
});
const initialState = usersAdapter.getInitialState({
isLoading: false,
error: null,
});
export const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async function(_, thunkAPI) {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error("Failed to fetch users");
}
return await response.json();
} catch (error) {
return thunkAPI.rejectWithValue(error.message);
}
}
);
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
addOneUser: usersAdapter.addOne,
updateOneUser: usersAdapter.updateOne,
removeOneUser: usersAdapter.removeOne,
},
extraReducers: function(builder) {
builder
.addCase(fetchUsers.pending, function(state) {
state.isLoading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, function(state, action) {
state.isLoading = false;
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, function(state, action) {
state.isLoading = false;
state.error = action.payload || "Unknown error";
});
},
});
export const { addOneUser, updateOneUser, removeOneUser } =
usersSlice.actions;
export default usersSlice.reducer;
Selectors від createEntityAdapter
// features/users/usersSelectors.js
import { createEntityAdapter } from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter();
export const usersSelectors = usersAdapter.getSelectors(function(state) {
return state.users;
});
// Доступні selector:
/// usersSelectors.selectAll(state)
/// usersSelectors.selectById(state, id)
/// usersSelectors.selectIds(state)
/// usersSelectors.selectEntities(state)
/// usersSelectors.selectTotal(state)
На практиці adapter та selectors краще визначати в тому ж модулі, де slice, щоб не дублювати логіку.
RTK Query: ще один спосіб розширити Redux
RTK Query — це інструмент для завантаження, кешування, оновлення і інвалідації серверних даних.
Якщо createAsyncThunk — це ручне керування async станами, то RTK Query — більш автоматизований підхід.
Коли RTK Query кращий за createAsyncThunk
- коли багато API-запитів
- коли потрібне кешування
- коли потрібен re-fetch
- коли потрібні query та mutation hooks
- коли не хочеться вручну писати isLoading та error для кожного запиту
Встановлення RTK Query
npm install @reduxjs/toolkit react-redux
Окремо ставити іншу бібліотеку не треба — RTK Query вже входить до складу Redux Toolkit.
Базовий приклад RTK Query API
// features/api/postsApi.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const postsApi = createApi({
reducerPath: "postsApi",
baseQuery: fetchBaseQuery({
baseUrl: "https://jsonplaceholder.typicode.com",
}),
endpoints: function(builder) {
return {
getPosts: builder.query({
query: function() {
return "/posts?_limit=5";
},
}),
getPostById: builder.query({
query: function(id) {
return "/posts/" + id;
},
}),
createPost: builder.mutation({
query: function(newPost) {
return {
url: "/posts",
method: "POST",
body: newPost,
};
},
}),
};
},
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
} = postsApi;
Підключення RTK Query у store
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import { postsApi } from "../features/api/postsApi";
export const store = configureStore({
reducer: {
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: function(getDefaultMiddleware) {
return getDefaultMiddleware().concat(postsApi.middleware);
},
});
Використання RTK Query у компоненті
// components/PostsRtkQuery.jsx
"use client";
import { useGetPostsQuery } from "../features/api/postsApi";
export default function PostsRtkQuery() {
const { data, error, isLoading } = useGetPostsQuery();
if (isLoading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error while loading posts</p>;
}
return (
<section>
<h2>Posts with RTK Query</h2>
<ul>
{data.map(function(post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
</section>
);
}
createAsyncThunk чи RTK Query
- createAsyncThunk — коли потрібна кастомна async логіка
- createAsyncThunk — коли запит є частиною складного бізнес-процесу
- RTK Query — коли це типова робота з API та кешем
- RTK Query — коли треба менше ручного коду
React Redux hooks
У сучасному React з Redux зазвичай використовують hooks:
- useSelector
- useDispatch
Що робить useSelector
const value = useSelector(function(state) {
return state.counter.value;
});
Він підписує компонент на шматок store. Якщо це значення зміниться, компонент перерендериться.
Що робить useDispatch
const dispatch = useDispatch();
dispatch(increment());
dispatch(fetchPosts());
Через dispatch ми надсилаємо action або thunk.
Рекомендована структура папок для RTK у React
src/
app/
store.js
providers.jsx
hooks.js
middleware/
loggerMiddleware.js
listenerMiddleware.js
features/
counter/
counterSlice.js
counterSelectors.js
posts/
postsSlice.js
postsThunks.js
users/
usersSlice.js
components/
Counter.jsx
PostsList.jsx
UsersList.jsx
Кастомні hooks для зручності
У JavaScript це не обов’язково, але дуже зручно.
// app/hooks.js
import { useDispatch, useSelector } from "react-redux";
export const useAppDispatch = function() {
return useDispatch();
};
export const useAppSelector = useSelector;
Redux Toolkit + Next.js (App Router)
У Next.js з App Router є важливий нюанс: Provider не можна просто вставити будь-де бездумно, бо Server Component і Client Component працюють по-різному.
Найпоширеніший підхід — створити окремий client component для Provider і обгорнути ним застосунок у layout.
Встановлення для Next.js
npm install @reduxjs/toolkit react-redux
Рекомендована структура для Next.js App Router
app/
layout.js
page.js
providers.js
lib/
store.js
features/
counter/
counterSlice.js
components/
Counter.js
Створення store для Next.js
// lib/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import postsReducer from "../features/posts/postsSlice";
export function makeStore() {
return configureStore({
reducer: {
counter: counterReducer,
posts: postsReducer,
},
});
}
У Next.js часто роблять функцію makeStore, а не просто export const store, щоб коректніше працювати з життєвим циклом застосунку.
Client Provider для Next.js
// app/providers.js
"use client";
import { useRef } from "react";
import { Provider } from "react-redux";
import { makeStore } from "../lib/store";
export default function StoreProvider({ children }) {
const storeRef = useRef(null);
if (!storeRef.current) {
// Створюємо store один раз на клієнті
storeRef.current = makeStore();
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
Підключення Provider у layout.js
// app/layout.js
import StoreProvider from "./providers";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<StoreProvider>{children}</StoreProvider>
</body>
</html>
);
}
Counter slice для Next.js
// features/counter/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } =
counterSlice.actions;
export default counterSlice.reducer;
Client component з Redux у Next.js
// components/Counter.js
"use client";
import { useSelector, useDispatch } from "react-redux";
import {
increment,
decrement,
incrementByAmount,
} from "../features/counter/counterSlice";
export default function Counter() {
const dispatch = useDispatch();
const value = useSelector(function(state) {
return state.counter.value;
});
return (
<section>
<h1>Redux Toolkit with Next.js</h1>
<p>Counter value: {value}</p>
<button onClick={function() {
dispatch(increment());
}}>
+1
</button>
<button onClick={function() {
dispatch(decrement());
}}>
-1
</button>
<button onClick={function() {
dispatch(incrementByAmount(10));
}}>
+10
</button>
</section>
);
}
Використання компонента на сторінці Next.js
// app/page.js
import Counter from "../components/Counter";
export default function HomePage() {
return (
<main>
<Counter />
</main>
);
}
Async логіка в Next.js + Redux Toolkit
Якщо компонент використовує useDispatch, useSelector, useEffect або будь-яку клієнтську інтерактивність, він має бути client component.
Тому Redux-компоненти в Next.js дуже часто починаються з:
"use client";
Приклад posts slice для Next.js
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
export const fetchPosts = createAsyncThunk(
"posts/fetchPosts",
async function(_, thunkAPI) {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5"
);
if (!response.ok) {
throw new Error("Failed to fetch posts");
}
return await response.json();
} catch (error) {
return thunkAPI.rejectWithValue(error.message);
}
}
);
const postsSlice = createSlice({
name: "posts",
initialState: {
items: [],
isLoading: false,
error: null,
},
reducers: {},
extraReducers: function(builder) {
builder
.addCase(fetchPosts.pending, function(state) {
state.isLoading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, function(state, action) {
state.isLoading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, function(state, action) {
state.isLoading = false;
state.error = action.payload || "Unknown error";
});
},
});
export default postsSlice.reducer;
Клієнтський компонент із завантаженням постів у Next.js
// components/PostsClient.js
"use client";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchPosts } from "../features/posts/postsSlice";
export default function PostsClient() {
const dispatch = useDispatch();
const items = useSelector(function(state) {
return state.posts.items;
});
const isLoading = useSelector(function(state) {
return state.posts.isLoading;
});
const error = useSelector(function(state) {
return state.posts.error;
});
useEffect(function() {
// Завантажуємо дані при першому рендері
dispatch(fetchPosts());
}, [dispatch]);
return (
<section>
<h2>Posts in Next.js</h2>
{isLoading && <p>Loading posts...</p>}
{error && <p>Error: {error}</p>}
<ul>
{items.map(function(post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
</section>
);
}
RTK Query + Next.js
Для Next.js RTK Query теж підходить дуже добре, особливо якщо у тебе звичайні клієнтські запити.
API slice для Next.js
// features/api/postsApi.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const postsApi = createApi({
reducerPath: "postsApi",
baseQuery: fetchBaseQuery({
baseUrl: "https://jsonplaceholder.typicode.com",
}),
endpoints: function(builder) {
return {
getPosts: builder.query({
query: function() {
return "/posts?_limit=5";
},
}),
};
},
});
export const { useGetPostsQuery } = postsApi;
Store з RTK Query у Next.js
// lib/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import { postsApi } from "../features/api/postsApi";
export function makeStore() {
return configureStore({
reducer: {
counter: counterReducer,
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: function(getDefaultMiddleware) {
return getDefaultMiddleware().concat(postsApi.middleware);
},
});
}
Компонент з RTK Query у Next.js
// components/PostsRtkQueryClient.js
"use client";
import { useGetPostsQuery } from "../features/api/postsApi";
export default function PostsRtkQueryClient() {
const { data = [], isLoading, error } = useGetPostsQuery();
if (isLoading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Failed to load posts</p>;
}
return (
<section>
<h2>RTK Query in Next.js</h2>
<ul>
{data.map(function(post) {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
</section>
);
}
Що, де і як створювати в Next.js
- lib/store.js — створення store або makeStore
- app/providers.js — клієнтський Provider для всього застосунку
- features/... — slice, thunk, selector, API slice
- components/... — клієнтські компоненти з useSelector і useDispatch
- app/layout.js — підключення StoreProvider
Логіка розподілу файлів
Усе, що стосується конкретної фічі, краще тримати всередині features. Наприклад, postsSlice, postsThunks, postsSelectors.
Глобальні налаштування — store, providers, middleware — краще тримати окремо в app або lib.
Коли Redux Toolkit справді потрібен
- коли є глобальний стан, потрібний багатьом компонентам
- коли є складні async сценарії
- коли є багато фіч і стан стає важко контролювати
- коли треба централізований і передбачуваний потік даних
Коли Redux Toolkit може бути зайвим
- дуже маленький застосунок
- стан живе лише в 1-2 компонентах
- достатньо useState, useReducer або context
Типові помилки при роботі з Redux Toolkit
- класти в store функції, Date, class instance або DOM елементи
- писати занадто багато логіки прямо в компонентах
- не виносити selector в окремі функції
- не розділяти slice за фічами
- змішувати локальний UI state і глобальний бізнес-стан без потреби
- використовувати Redux там, де вистачає local state
- забувати про "use client" у Next.js компонентах з Redux hooks
- писати один величезний slice на весь проєкт
Шпаргалка по основних API Redux Toolkit
- configureStore — створює store
- createSlice — створює slice, reducer і actions
- createAsyncThunk — async логіка з pending/fulfilled/rejected
- extraReducers — реакція на зовнішні action
- createListenerMiddleware — побічні ефекти на action або state
- createEntityAdapter — нормалізація списків сутностей
- createApi / fetchBaseQuery — RTK Query
- useSelector — читання store в React
- useDispatch — dispatch action або thunk
Міні-пам’ятка по порядку роботи
- Створити slice через createSlice
- Додати reducer у configureStore
- Підключити Provider
- У компоненті читати дані через useSelector
- Оновлювати дані через dispatch
- Для async запитів використати createAsyncThunk або RTK Query
- Для складнішого розширення додати middleware або listener middleware
Повна картина для фронтендера
Redux Toolkit — це не окрема “магія”, а зручний шар над класичним Redux.
createSlice і configureStore закривають базову роботу зі state.
createAsyncThunk допомагає працювати з асинхронними сценаріями.
middleware, listener middleware, addMatcher і createEntityAdapter — це вже розширення Redux для більш дорослої архітектури.
RTK Query — ще один рівень розширення, коли треба зручно працювати з API, кешуванням і запитами.
У React Redux Toolkit найчастіше використовується через useSelector та useDispatch, а в Next.js App Router — через окремий client provider та клієнтські компоненти.
Короткий підсумок
Якщо тобі треба просто і правильно почати працювати з Redux у сучасному React-проєкті, бери саме Redux Toolkit.
Якщо тобі треба зберігати глобальний стан — використовуй createSlice.
Якщо треба тягнути дані з сервера — використовуй createAsyncThunk або RTK Query.
Якщо треба складніше розширити поведінку Redux — підключай middleware, listener middleware, matcher і entity adapter.
А для Next.js пам’ятай головне правило: Provider і компоненти з Redux hooks повинні бути client-side там, де це потрібно.
Internationalization (i18n) — повний гайд для Front-end розробника
Internationalization (i18n) — це підготовка застосунку до роботи з різними мовами, регіонами, форматами дат, чисел, валют і текстових правил.
Простими словами: i18n — це не тільки переклад кнопок і заголовків, а й правильне відображення дат, часу, валют, чисел, множини, напрямку тексту та локалізованих маршрутів.
Дуже важливо розуміти різницю між термінами:
- i18n (internationalization) — підготовка проєкту до підтримки різних мов
- l10n (localization) — адаптація під конкретну мову або регіон
-
locale — набір мовних і регіональних правил, наприклад
en,en-US,uk,de-DE -
translation key — ключ перекладу, наприклад
common.buttons.save -
namespace — окрема група перекладів, наприклад
common,home,profile - interpolation — вставка змінних у перекладений рядок
- pluralization — зміна форми слова залежно від кількості
- fallback language — запасна мова, якщо переклад не знайдено
Що саме входить у i18n
- Переклад статичного тексту
- Переклад динамічного тексту
- Форматування дат і часу
- Форматування чисел і валют
- Підтримка множини
- Підтримка різних мовних напрямків, наприклад RTL
-
Локалізовані URL, наприклад
/en/aboutі/uk/about - Збереження вибраної користувачем мови
- Автовизначення мови браузера
- Ліниве завантаження перекладів
Що фронтендер повинен знати обов’язково
- Не хардкодити текст прямо в JSX
- Не зберігати переклади в компонентах
- Розділяти переклади по файлах і namespace
- Використовувати зрозумілі ключі перекладу
- Окремо думати про тексти, а окремо про форматування дат і чисел
- Передбачати fallback language
- Пам’ятати, що мова і країна — не завжди одне й те саме
- Не склеювати речення з шматків, якщо це можна уникнути
- Обов’язково враховувати pluralization
- Не забувати про доступність і читабельність текстів після перекладу
Нативний JavaScript i18n через Intl API
Навіть якщо ти використовуєш бібліотеку для перекладів,
форматування дат, чисел, валют і відносного часу дуже часто
роблять через нативний
Intl.
Форматування дати
const date = new Date();
const ukDate = new Intl.DateTimeFormat("uk-UA").format(date);
const usDate = new Intl.DateTimeFormat("en-US").format(date);
console.log(ukDate);
console.log(usDate);
Один і той самий об’єкт Date у різних локалях
може виглядати по-різному.
Форматування дати з опціями
const date = new Date();
const formatted = new Intl.DateTimeFormat("uk-UA", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
hour: "2-digit",
minute: "2-digit",
}).format(date);
console.log(formatted);
Форматування чисел
const value = 1234567.89;
console.log(new Intl.NumberFormat("uk-UA").format(value));
console.log(new Intl.NumberFormat("en-US").format(value));
console.log(new Intl.NumberFormat("de-DE").format(value));
Форматування валюти
const price = 1999.99;
const uaPrice = new Intl.NumberFormat("uk-UA", {
style: "currency",
currency: "UAH",
}).format(price);
const usPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
console.log(uaPrice);
console.log(usPrice);
Форматування відсотків
const discount = 0.18;
const formatted = new Intl.NumberFormat("uk-UA", {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(discount);
console.log(formatted);
Форматування відносного часу
const rtf = new Intl.RelativeTimeFormat("uk-UA", {
numeric: "auto",
});
console.log(rtf.format(-1, "day"));
console.log(rtf.format(3, "hour"));
Це зручно для текстів типу “вчора”, “через 3 години”, “2 дні тому”.
Форматування списків
const items = ["React", "Next.js", "i18next"];
const formatter = new Intl.ListFormat("uk-UA", {
style: "long",
type: "conjunction",
});
console.log(formatter.format(items));
Множина через Intl.PluralRules
const pluralRules = new Intl.PluralRules("uk-UA");
console.log(pluralRules.select(1));
console.log(pluralRules.select(2));
console.log(pluralRules.select(5));
Це корисно для власної логіки pluralization, хоча в реальних React-проєктах частіше використовують готову підтримку множини в i18next.
Найпопулярніший стек для i18n у React
Найчастіше для React використовують зв’язку
i18next + react-i18next.
Ролі в них різні:
i18next— ядро системи перекладів-
react-i18next— інтеграція i18next з React -
i18next-browser-languagedetector— визначення мови браузера -
i18next-http-backend— завантаження перекладів по HTTP
Що встановити
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend
Типова структура перекладів
src/
i18n.js
main.jsx
App.jsx
locales/
en/
common.json
home.json
uk/
common.json
home.json
Приклад файлу перекладу en/common.json
{
"appTitle": "Internationalization guide",
"buttons": {
"save": "Save",
"cancel": "Cancel",
"changeLanguage": "Change language"
},
"welcome": "Welcome, {{name}}!",
"items_one": "{{count}} item",
"items_few": "{{count}} items",
"items_many": "{{count}} items",
"items_other": "{{count}} items"
}
Приклад файлу перекладу uk/common.json
{
"appTitle": "Гайд з інтернаціоналізації",
"buttons": {
"save": "Зберегти",
"cancel": "Скасувати",
"changeLanguage": "Змінити мову"
},
"welcome": "Вітаю, {{name}}!",
"items_one": "{{count}} елемент",
"items_few": "{{count}} елементи",
"items_many": "{{count}} елементів",
"items_other": "{{count}} елемента"
}
Базове налаштування i18n.js
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import ukCommon from "./locales/uk/common.json";
i18n.use(initReactI18next).init({
resources: {
en: {
common: enCommon,
},
uk: {
common: ukCommon,
},
},
lng: "uk",
fallbackLng: "en",
defaultNS: "common",
ns: ["common"],
interpolation: {
escapeValue: false,
},
});
export default i18n;
Що тут відбувається
resources— усі підключені перекладиlng— поточна мова за замовчуваннямfallbackLng— запасна моваdefaultNS— namespace за замовчуванням-
interpolation.escapeValue: false— стандартне налаштування для React
Підключення i18n в React
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./i18n";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Використання useTranslation
import { useTranslation } from "react-i18next";
function App() {
const { t } = useTranslation();
return (
<main>
<h1>{t("appTitle")}</h1>
<button>{t("buttons.save")}</button>
<button>{t("buttons.cancel")}</button>
</main>
);
}
export default App;
Interpolation — підстановка змінних
import { useTranslation } from "react-i18next";
function WelcomeMessage() {
const { t } = useTranslation();
return <p>{t("welcome", { name: "Viktor" })}</p>;
}
export default WelcomeMessage;
Pluralization — множина
import { useTranslation } from "react-i18next";
function CartInfo() {
const { t } = useTranslation();
return (
<div>
<p>{t("items", { count: 1 })}</p>
<p>{t("items", { count: 2 })}</p>
<p>{t("items", { count: 5 })}</p>
</div>
);
}
export default CartInfo;
Для pluralization i18next сам підбере правильну форму на
основі
count і правил мови.
Зміна мови по кнопці
import { useTranslation } from "react-i18next";
function LanguageSwitcher() {
const { i18n, t } = useTranslation();
const changeToUk = () => {
i18n.changeLanguage("uk");
};
const changeToEn = () => {
i18n.changeLanguage("en");
};
return (
<div>
<p>{t("buttons.changeLanguage")}</p>
<button onClick={changeToUk}>Українська</button>
<button onClick={changeToEn}>English</button>
</div>
);
}
export default LanguageSwitcher;
Як зберегти вибрану мову в localStorage
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
const savedLanguage = localStorage.getItem("language") || "uk";
i18n.use(initReactI18next).init({
resources: {
en: {
common: {
appTitle: "Internationalization guide"
}
},
uk: {
common: {
appTitle: "Гайд з інтернаціоналізації"
}
}
},
lng: savedLanguage,
fallbackLng: "en",
defaultNS: "common",
interpolation: {
escapeValue: false,
},
});
i18n.on("languageChanged", (lng) => {
localStorage.setItem("language", lng);
});
export default i18n;
Автовизначення мови браузера
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
common: {
appTitle: "Internationalization guide"
}
},
uk: {
common: {
appTitle: "Гайд з інтернаціоналізації"
}
}
},
fallbackLng: "en",
defaultNS: "common",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator", "htmlTag"],
caches: ["localStorage"],
},
});
export default i18n;
Namespaces — коли перекладів багато
Якщо застосунок росте, краще не тримати все в одному файлі
common.json. Зручніше розбити переклади за
сторінками або модулями.
src/
locales/
en/
common.json
home.json
profile.json
uk/
common.json
home.json
profile.json
Підключення кількох namespace
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import enHome from "./locales/en/home.json";
import ukCommon from "./locales/uk/common.json";
import ukHome from "./locales/uk/home.json";
i18n.use(initReactI18next).init({
resources: {
en: {
common: enCommon,
home: enHome,
},
uk: {
common: ukCommon,
home: ukHome,
},
},
lng: "uk",
fallbackLng: "en",
ns: ["common", "home"],
defaultNS: "common",
interpolation: {
escapeValue: false,
},
});
export default i18n;
Використання namespace у компоненті
import { useTranslation } from "react-i18next";
function HomePage() {
const { t } = useTranslation("home");
return (
<section>
<h2>{t("title")}</h2>
<p>{t("description")}</p>
</section>
);
}
export default HomePage;
Доступ до іншого namespace через префікс
import { useTranslation } from "react-i18next";
function Example() {
const { t } = useTranslation(["common", "home"]);
return (
<div>
<h2>{t("home:title")}</h2>
<button>{t("common:buttons.save")}</button>
</div>
);
}
export default Example;
Trans — коли в перекладі є HTML або React елементи
import { Trans } from "react-i18next";
function TermsText() {
return (
<p>
<Trans i18nKey="termsText">
I agree with the <a href="/terms">terms of use</a>.
</Trans>
</p>
);
}
export default TermsText;
Приклад перекладу для Trans
{
"termsText": "Я погоджуюсь з <1>умовами використання</1>."
}
Динамічне завантаження перекладів по HTTP
Це зручно, якщо ти не хочеш імпортувати всі JSON у bundle.
import i18n from "i18next";
import HttpBackend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
ns: ["common"],
defaultNS: "common",
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
interpolation: {
escapeValue: false,
},
});
export default i18n;
Структура public для HTTP backend
public/
locales/
en/
common.json
home.json
uk/
common.json
home.json
Internationalization у звичайному React: CRA та Vite
На рівні використання i18next різниця між CRA і
Vite невелика. Основна логіка однакова:
- створюєш
i18n.js - підключаєш його в entry file
-
зберігаєш переклади в
srcабоpublic -
використовуєш
useTranslationв компонентах
CRA — типова структура
src/
i18n.js
index.js
App.js
components/
locales/
Vite — типова структура
src/
i18n.js
main.jsx
App.jsx
components/
locales/
Головна різниця між CRA і Vite
- У CRA entry file зазвичай
src/index.js -
У Vite entry file зазвичай
src/main.jsx -
У Vite частіше зручно працювати з файлами в
publicабо через модульні імпорти -
Логіка
i18next,useTranslationі структура словників практично однакова
Приклад для Vite
// src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./i18n";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Приклад App.jsx у Vite або CRA
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "./components/LanguageSwitcher";
function App() {
const { t } = useTranslation();
return (
<main>
<h1>{t("appTitle")}</h1>
<p>{t("welcome", { name: "Viktor" })}</p>
<LanguageSwitcher />
</main>
);
}
export default App;
React + i18n: правильна організація проєкту
Що де зберігати
src/i18n.js— уся конфігурація i18next-
src/locales/абоpublic/locales/— файли перекладів -
components/LanguageSwitcher.jsx— перемикач мови -
hooks/— якщо є власні обгортки над форматуванням -
utils/formatters.js— форматування дат, чисел, валют через Intl
Приклад formatters.js
export function formatPrice(value, locale, currency) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(value);
}
export function formatDate(value, locale) {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
}
Використання formatters.js у React
import { useTranslation } from "react-i18next";
import { formatPrice, formatDate } from "./utils/formatters";
function ProductCard() {
const { i18n } = useTranslation();
const locale = i18n.language === "uk" ? "uk-UA" : "en-US";
const currency = i18n.language === "uk" ? "UAH" : "USD";
return (
<article>
<p>{formatPrice(2500, locale, currency)}</p>
<p>{formatDate("2026-03-11", locale)}</p>
</article>
);
}
export default ProductCard;
Next.js + Internationalization
У Next.js тема i18n ширша, ніж у звичайному React, бо тут вже є маршрути, серверні компоненти, SEO і різні router-моделі.
Головне:
- Pages Router і App Router налаштовуються по-різному
- Треба думати не тільки про переклади, а й про локалізовані URL
- Треба враховувати серверний і клієнтський рендеринг
Next.js Pages Router + i18n
У Pages Router є вбудована підтримка i18n routing через
next.config.js.
Структура проєкту Pages Router
pages/
_app.js
index.js
about.js
public/
locales/
en/
common.json
uk/
common.json
next.config.js
i18n.js
Налаштування next.config.js для Pages Router
/** @type {import("next").NextConfig} */
const nextConfig = {
i18n: {
locales: ["en", "uk"],
defaultLocale: "uk",
localeDetection: true,
},
};
module.exports = nextConfig;
Що це дає
- Маршрути типу
/en,/uk - Автоматичне визначення locale
- Доступ до locale через router
i18n.js для Pages Router
import i18n from "i18next";
import HttpBackend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
i18n.use(HttpBackend).use(initReactI18next).init({
fallbackLng: "en",
ns: ["common"],
defaultNS: "common",
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
interpolation: {
escapeValue: false,
},
});
export default i18n;
Підключення в pages/_app.js
import "../styles/globals.css";
import "../i18n";
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />;
}
Використання router locale у Pages Router
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
export default function HomePage() {
const { locale, asPath } = useRouter();
const { t } = useTranslation();
return (
<main>
<h1>{t("appTitle")}</h1>
<p>Current locale: {locale}</p>
<Link href={asPath} locale="uk">
Українська
</Link>
<Link href={asPath} locale="en">
English
</Link>
</main>
);
}
getStaticProps з locale
export async function getStaticProps(context) {
const { locale } = context;
return {
props: {
currentLocale: locale,
},
};
}
getServerSideProps з locale
export async function getServerSideProps(context) {
const { locale } = context;
return {
props: {
currentLocale: locale,
},
};
}
Next.js App Router + i18n
В App Router підхід частіше інший: замість класичної
конфігурації сторінок ти зазвичай робиш сегмент маршруту з
locale, наприклад
app/[lang]/page.jsx.
Типова структура App Router
app/
[lang]/
layout.jsx
page.jsx
about/
page.jsx
i18n/
dictionaries/
en.json
uk.json
getDictionary.js
middleware.js
Локалі окремим масивом
// i18n/config.js
export const locales = ["en", "uk"];
export const defaultLocale = "uk";
Файл словників
// i18n/dictionaries/en.json
{
"title": "Internationalization guide",
"description": "Learn i18n in React and Next.js"
}
Український словник
// i18n/dictionaries/uk.json
{
"title": "Гайд з інтернаціоналізації",
"description": "Вивчай i18n у React і Next.js"
}
Функція отримання словника
// i18n/getDictionary.js
const dictionaries = {
en: () => import("./dictionaries/en.json").then((module) => module.default),
uk: () => import("./dictionaries/uk.json").then((module) => module.default),
};
export async function getDictionary(locale) {
return dictionaries[locale]();
}
app/[lang]/page.jsx
import { getDictionary } from "../../i18n/getDictionary";
export default async function HomePage({ params }) {
const { lang } = await params;
const dict = await getDictionary(lang);
return (
<main>
<h1>{dict.title}</h1>
<p>{dict.description}</p>
</main>
);
}
app/[lang]/layout.jsx
export default async function RootLayout({ children, params }) {
const { lang } = await params;
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}
Генерація статичних маршрутів
import { locales } from "../../i18n/config";
export async function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
middleware.js для перенаправлення на locale
import { NextResponse } from "next/server";
const locales = ["en", "uk"];
const defaultLocale = "uk";
function getLocale(request) {
const acceptLanguage = request.headers.get("accept-language");
if (!acceptLanguage) {
return defaultLocale;
}
if (acceptLanguage.toLowerCase().includes("uk")) {
return "uk";
}
if (acceptLanguage.toLowerCase().includes("en")) {
return "en";
}
return defaultLocale;
}
export function middleware(request) {
const { pathname } = request.nextUrl;
const pathnameHasLocale = locales.some((locale) => {
return pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`;
});
if (pathnameHasLocale) {
return NextResponse.next();
}
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ["/((?!_next|api|favicon.ico).*)"],
};
Клієнтський перемикач мови в App Router
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const locales = ["uk", "en"];
export default function LanguageSwitcher() {
const pathname = usePathname();
const currentLocale = locales.find((locale) => {
return pathname.startsWith(`/${locale}`);
});
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, "") || "/";
return (
<div>
<Link href={`/uk${pathWithoutLocale}`}>Українська</Link>
<Link href={`/en${pathWithoutLocale}`}>English</Link>
</div>
);
}
Коли в App Router брати react-i18next, а коли словники
- Якщо тобі потрібен простий контент у Server Components — часто достатньо словників через імпорт JSON
-
Якщо у тебе складний клієнтський UI, перемикання мови на
льоту, namespace, pluralization і багато компонентів —
зручно брати
i18next + react-i18next -
У змішаних проєктах теж часто комбінують: серверні
словники плюс
Intlабо клієнтські переклади там, де це потрібно
Next.js + react-i18next в App Router
Можна використовувати і повноцінний
react-i18next, але треба акуратніше розділяти
server/client логіку.
Приклад клієнтського i18n провайдера
"use client";
import i18n from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
const resources = {
en: {
common: {
title: "Internationalization guide",
},
},
uk: {
common: {
title: "Гайд з інтернаціоналізації",
},
},
};
if (!i18n.isInitialized) {
i18n.use(initReactI18next).init({
resources,
lng: "uk",
fallbackLng: "en",
defaultNS: "common",
interpolation: {
escapeValue: false,
},
});
}
export default function I18nProvider({ children }) {
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
Підключення провайдера в layout
import I18nProvider from "./I18nProvider";
export default function RootLayout({ children }) {
return (
<html lang="uk">
<body>
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);
}
Клієнтський компонент з useTranslation
"use client";
import { useTranslation } from "react-i18next";
export default function HeroTitle() {
const { t } = useTranslation();
return <h1>{t("title")}</h1>;
}
SEO та i18n
Якщо у сайту кілька мов, треба думати і про SEO:
- окремі URL для різних мов
-
правильний атрибут
langнаhtml - локалізовані title і description
- послідовна структура маршрутів
Приклад metadata в App Router
import { getDictionary } from "../../i18n/getDictionary";
export async function generateMetadata({ params }) {
const { lang } = await params;
const dict = await getDictionary(lang);
return {
title: dict.title,
description: dict.description,
};
}
RTL мови
Якщо ти колись додаватимеш арабську або іврит, треба враховувати не тільки переклад тексту, а і напрямок інтерфейсу.
Приклад перемикання dir
const isRTL = ["ar", "he", "fa"].includes(currentLanguage);
document.documentElement.lang = currentLanguage;
document.documentElement.dir = isRTL ? "rtl" : "ltr";
Тестування компонентів з i18n
У тестах часто не потрібно піднімати повну i18n
конфігурацію. Достатньо замокати t.
Приклад простого моку
jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key) => key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
}));
Що це дає
Компоненти рендеряться без реальних словників, а замість перекладу просто повертається ключ. Це часто достатньо для unit-тестів UI.
Типові помилки в i18n
- Хардкодять тексти прямо в JSX
- Не використовують fallback language
- Змішують переклади і форматування в одному місці без структури
-
Використовують нечитабельні ключі типу
text1,text2 - Тримають усі переклади в одному величезному JSON
- Не враховують pluralization
- Склеюють речення з фрагментів, через що переклад ламається
- Не синхронізують назви ключів між мовами
- Не оновлюють
html lang -
Плутають locale
enі регіональний форматen-US
Хороші практики
- Роби один файл конфігурації i18n
- Тримай переклади в окремих JSON
- Використовуй namespace для великих проєктів
- Для дат і валют використовуй
Intl - Тримай ключі перекладу семантичними
- Передбачай fallback language
- Тестуй UI хоча б на двох мовах
- Перевіряй, як виглядають довгі тексти після перекладу
- У Next.js продумуй структуру URL одразу
- Не роби i18n “після завершення проєкту”, плануй його з початку
Міні-шпаргалка по командах
Встановлення в React або Vite
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend
Базове підключення
import "./i18n";
Отримання перекладу
const { t } = useTranslation();
Переклад простого ключа
t("appTitle");
Interpolation
t("welcome", { name: "Viktor" });
Pluralization
t("items", { count: 5 });
Зміна мови
i18n.changeLanguage("uk");
Дата через Intl
new Intl.DateTimeFormat("uk-UA").format(new Date());
Число через Intl
new Intl.NumberFormat("uk-UA").format(12345.67);
Валюта через Intl
new Intl.NumberFormat("uk-UA", {
style: "currency",
currency: "UAH",
}).format(1200);
Повна картина для фронтендера
i18n — це не окрема дрібна бібліотека, а частина архітектури інтерфейсу.
У звичайному React ти в основному працюєш з перекладами компонентів, збереженням мови й форматуванням даних.
У Next.js до цього додаються маршрути, серверний рендеринг, metadata і SEO.
Найпрактичніший підхід:
-
Для перекладів інтерфейсу —
i18next + react-i18next - Для дат, чисел, валют —
Intl - Для Next.js Pages Router — вбудований i18n routing плюс переклади
-
Для Next.js App Router — locale segment, словники або
react-i18nextзалежно від задачі
Що варто вивчити після цього гайду
- Lazy loading namespace
- Локалізовані SEO metadata
- RTL layout
- Побудова типізованих translation keys
- Переклади для форм валідації
- Інтеграція i18n з Zustand, Redux Toolkit або Context
- Тестування multi-language UI
TypeScript + React — повний гайд для Front-end розробника
TypeScript — це надбудова над JavaScript, яка додає статичну типізацію. Вона допомагає раніше знаходити помилки, краще описувати структуру даних, робити код зрозумілішим і комфортніше працювати з великими React-проєктами.
У React TypeScript найчастіше використовують для типізації props, state, event handlers, API-відповідей, refs, custom hooks, context, форм і компонентної архітектури загалом.
Якщо коротко: JavaScript каже “цей код працює зараз”, а TypeScript додає “і ще ми можемо перевірити, чи ти взагалі правильно цим кодом користуєшся”.
Що треба вивчити в цій темі
- Що таке типи, інтерфейси, type aliases
- Різниця між JavaScript і TypeScript
- Як типізувати змінні, масиви, об’єкти, функції
- Що таке union, literal, optional, readonly типи
- Що таке generics і навіщо вони потрібні
- Як TypeScript працює в React-компонентах
- Як типізувати props
- Як типізувати state
- Як типізувати події forms та buttons
- Як типізувати useRef, useContext, useReducer
- Як працювати з TypeScript у Vite
- Як працювати з TypeScript у Next.js
Навіщо TypeScript у React
TypeScript особливо корисний у React з кількох причин.
- Props стають чітко описаними
- Помилки у переданих даних видно ще до запуску
- IDE краще підказує властивості та методи
- Зручніше підтримувати великий код
- Легше рефакторити компоненти
- Простіше працювати з API та формами
Базові типи TypeScript
Почнемо з найголовнішого: як описуються типи у звичайному TS-коді.
Примітивні типи
const userName: string = "Viktor";
const age: number = 25;
const isOnline: boolean = true;
const nothing: null = null;
const notDefined: undefined = undefined;
Після двокрапки ми вказуємо тип значення. Це базова форма
запису:
назваЗмінної: тип.
Масиви
const numbers: number[] = [1, 2, 3, 4];
const names: string[] = ["Anna", "Oleh", "Maks"];
const flags: boolean[] = [true, false, true];
Запис number[] означає “масив чисел”.
Аналогічно string[] — масив рядків.
Об’єкти
const user: { id: number; name: string; isAdmin: boolean } = {
id: 1,
name: "Viktor",
isAdmin: false,
};
Такий запис працює, але для реального проєкту зазвичай
зручніше винести тип окремо через type або
interface.
Type alias
type User = {
id: number;
name: string;
isAdmin: boolean;
};
const firstUser: User = {
id: 1,
name: "Viktor",
isAdmin: false,
};
const secondUser: User = {
id: 2,
name: "Olha",
isAdmin: true,
};
type зручно використовувати для об’єктів, union
типів, композиції типів і багатьох інших сценаріїв.
Interface
interface Product {
id: number;
title: string;
price: number;
}
const item: Product = {
id: 10,
title: "Laptop",
price: 40000,
};
interface часто використовують для опису форми
об’єктів, props і структур даних. На практиці і
type, і interface
використовують часто.
Optional поля
type Profile = {
id: number;
name: string;
avatar?: string;
};
const profileA: Profile = {
id: 1,
name: "Viktor",
};
const profileB: Profile = {
id: 2,
name: "Olha",
avatar: "/images/olha.jpg",
};
Знак ? означає, що поле не є обов’язковим.
Union типи
let statusText: string | number;
statusText = "loading";
statusText = 200;
Union означає: значення може бути одного з кількох типів.
Literal типи
type Theme = "light" | "dark";
let currentTheme: Theme = "light";
currentTheme = "dark";
Тут значення може бути не будь-яким рядком, а лише одним із чітко заданих варіантів.
Readonly
type Settings = {
readonly apiUrl: string;
readonly appName: string;
};
const settings: Settings = {
apiUrl: "https://api.example.com",
appName: "My App",
};
Властивості з readonly не можна змінювати після
створення об’єкта.
Типізація функцій
function sum(a: number, b: number): number {
return a + b;
}
function logMessage(message: string): void {
console.log(message);
}
const multiply = (a: number, b: number): number => {
return a * b;
};
Після дужок функції ми вказуємо тип результату:
(a: number, b: number): number.
Типізація callback
type ActionHandler = (value: string) => void;
const handleAction: ActionHandler = (value) => {
console.log(value);
};
Це дуже корисно для props, коли компонент приймає функцію від батьківського компонента.
Any, unknown, never
let something: any = "text";
something = 100;
something = true;
let data: unknown = "hello";
function throwError(message: string): never {
throw new Error(message);
}
anyвимикає нормальну перевірку типів-
unknownбезпечніший заany -
neverозначає, що функція не завершується нормально
У реальному коді краще максимально уникати any.
Type vs Interface
На базовому рівні обидва підходи підходять для опису об’єктів. У React-коді ти побачиш обидва варіанти.
type UserType = {
id: number;
name: string;
};
interface UserInterface {
id: number;
name: string;
}
Практичне правило для початку:
- для props і простих об’єктів можна брати будь-який із двох варіантів
- для union типів зручніше
type -
для об’єктних контрактів часто використовують
interface
Generics — одна з найважливіших тем
Generics дозволяють створювати гнучкі типи, які працюють з різними даними, але не втрачають типізацію.
Простий generic
function identity<T>(value: T): T {
return value;
}
const text = identity<string>("Hello");
const count = identity<number>(5);
Тут T — це “тип-параметр”. Функція повертає той
самий тип, який отримала.
Generic для масивів
function getFirstItem<T>(items: T[]): T | undefined {
return items[0];
}
const firstNumber = getFirstItem([10, 20, 30]);
const firstString = getFirstItem(["a", "b", "c"]);
TypeScript сам виведе тип залежно від переданого масиву.
Що потрібно знати про TypeScript у React
У React TypeScript найчастіше застосовується в таких місцях:
- типізація props
- типізація state через useState
- типізація events: click, change, submit
- типізація refs
- типізація children
- типізація API-відповідей
- типізація контексту та reducer
React + TypeScript. Props
Props — це перше, що зазвичай типізують у React-компонентах.
Найпростіший приклад props
type GreetingProps = {
name: string;
};
function Greeting({ name }: GreetingProps) {
return <h2>Hello, {name}</h2>;
}
Компонент очікує лише один prop — name, і він
повинен бути рядком.
Кілька props
type ProductCardProps = {
title: string;
price: number;
inStock: boolean;
};
function ProductCard({ title, price, inStock }: ProductCardProps) {
return (
<div>
<h3>{title}</h3>
<p>Price: {price} UAH</p>
<p>{inStock ? "In stock" : "Out of stock"}</p>
</div>
);
}
Optional props
type AvatarProps = {
name: string;
imageUrl?: string;
};
function Avatar({ name, imageUrl }: AvatarProps) {
return (
<div>
{imageUrl ? (
<img src={imageUrl} alt={name} />
) : (
<span>No image</span>
)}
<p>{name}</p>
</div>
);
}
Якщо imageUrl не передали, компонент все одно
працює.
Props з масивом об’єктів
type User = {
id: number;
name: string;
};
type UserListProps = {
users: User[];
};
function UserList({ users }: UserListProps) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Props з callback функцією
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
Якщо треба передати параметр у callback, тип теж треба описати.
Callback з параметром
type UserItemProps = {
id: number;
name: string;
onDelete: (id: number) => void;
};
function UserItem({ id, name, onDelete }: UserItemProps) {
return (
<div>
<span>{name}</span>
<button onClick={() => onDelete(id)}>Delete</button>
</div>
);
}
Children у props
import { ReactNode } from "react";
type CardProps = {
title: string;
children: ReactNode;
};
function Card({ title, children }: CardProps) {
return (
<section>
<h3>{title}</h3>
<div>{children}</div>
</section>
);
}
Для вмісту між відкриваючим і закриваючим тегом компонента
найчастіше використовують ReactNode.
Props з union типами
type BadgeProps = {
status: "success" | "error" | "warning";
};
function Badge({ status }: BadgeProps) {
return <span>{status}</span>;
}
Це корисно, коли компонент повинен приймати не будь-який рядок, а лише конкретні варіанти.
Props для стилів або класів
type TitleProps = {
text: string;
className?: string;
};
function Title({ text, className }: TitleProps) {
return <h2 className={className}>{text}</h2>;
}
Деструктуризація props окремо від типу
type MessageProps = {
title: string;
description: string;
};
function Message(props: MessageProps) {
const { title, description } = props;
return (
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}
Це зручний стиль, якщо в середині компонента потрібно працювати з усім об’єктом props.
Приклад помилки, яку ловить TypeScript
type CounterProps = {
count: number;
};
function Counter({ count }: CounterProps) {
return <p>{count}</p>;
}
// Помилка: count очікує number, а не string
// <Counter count="10" />
React + TypeScript. State
У більшості випадків TypeScript сам правильно виводить тип
для
useState, але важливо розуміти, коли цього
достатньо, а коли краще явно вказати тип.
Простий state
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Тут TypeScript сам розуміє, що count — це
число.
Рядковий state
const [name, setName] = useState("Viktor");
Boolean state
const [isOpen, setIsOpen] = useState(false);
Коли треба явно вказувати тип
type User = {
id: number;
name: string;
};
const [user, setUser] = useState<User | null>(null);
Тут початкове значення null, тому без явного
типу TS не зможе правильно зрозуміти, що потім там буде
об’єкт користувача.
State з масивом
type Todo = {
id: number;
text: string;
done: boolean;
};
const [todos, setTodos] = useState<Todo[]>([]);
State з об’єктом
type FormState = {
email: string;
password: string;
};
const [form, setForm] = useState<FormState>({
email: "",
password: "",
});
Оновлення state об’єкта
setForm((prev) => ({
...prev,
email: "test@example.com",
}));
TypeScript перевіряє, щоб структура нового об’єкта
відповідала типу
FormState.
State з union типом
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">(
"idle",
);
Це дуже зручний підхід для контролю UI-станів.
Складніший приклад state для завантаження даних
type Post = {
id: number;
title: string;
body: string;
};
function Posts() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<section>
{isLoading && <p>Loading...</p>}
{error && <p>{error}</p>}
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</section>
);
}
React + TypeScript. Events
Події — одна з найважливіших практичних тем. Тут найчастіше використовують готові типи React, наприклад:
-
React.MouseEvent<HTMLButtonElement> -
React.ChangeEvent<HTMLInputElement> React.FormEvent<HTMLFormElement>-
React.KeyboardEvent<HTMLInputElement>
Click event для button
import { MouseEvent } from "react";
function ActionButton() {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
console.log(event.currentTarget.textContent);
};
return <button onClick={handleClick}>Click me</button>;
}
currentTarget — це саме той елемент, на який
навішаний обробник.
Change event для input
import { ChangeEvent, useState } from "react";
function NameInput() {
const [name, setName] = useState("");
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
};
return (
<div>
<input type="text" value={name} onChange={handleChange} />
<p>Name: {name}</p>
</div>
);
}
Textarea event
import { ChangeEvent, useState } from "react";
function CommentBox() {
const [comment, setComment] = useState("");
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setComment(event.target.value);
};
return <textarea value={comment} onChange={handleChange} />;
}
Select event
import { ChangeEvent, useState } from "react";
function ThemeSelect() {
const [theme, setTheme] = useState("light");
const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
setTheme(event.target.value);
};
return (
<select value={theme} onChange={handleChange}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
Submit event для форми
import { FormEvent, useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log("Submitted:", email);
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Send</button>
</form>
);
}
Keyboard event
import { KeyboardEvent } from "react";
function SearchInput() {
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
console.log("Search start");
}
};
return <input type="text" onKeyDown={handleKeyDown} />;
}
Focus event
import { FocusEvent } from "react";
function EmailInput() {
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
console.log("Focused:", event.target.name);
};
return <input name="email" type="email" onFocus={handleFocus} />;
}
Change handler як окремий reusable тип
type InputChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => void;
const handleEmailChange: InputChangeHandler = (event) => {
console.log(event.target.value);
};
Повний приклад: форма з TypeScript
import { ChangeEvent, FormEvent, useState } from "react";
type LoginFormState = {
email: string;
password: string;
};
function LoginForm() {
const [form, setForm] = useState<LoginFormState>({
email: "",
password: "",
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
Це один із найтиповіших шаблонів для форм. Особливо важливо, що state має окремий тип, а події явно типізовані.
Типізація списків і API-даних
type Post = {
userId: number;
id: number;
title: string;
body: string;
};
async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const data: Post[] = await response.json();
return data;
}
Якщо ти знаєш форму даних з API, її бажано описати окремим типом.
Використання API-типу в React
import { useEffect, useState } from "react";
type Post = {
id: number;
title: string;
body: string;
};
function PostsList() {
const [posts, setPosts] = useState<Post[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadPosts() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5",
);
const data: Post[] = await response.json();
setPosts(data);
} catch (err) {
setError("Loading error");
}
}
loadPosts();
}, []);
return (
<section>
{error && <p>{error}</p>}
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</section>
);
}
React + TypeScript. useRef
import { useRef } from "react";
function SearchBox() {
const inputRef = useRef<HTMLInputElement | null>(null);
const handleFocus = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>Focus input</button>
</div>
);
}
?. тут потрібен тому, що під час першого
рендера current ще може бути null.
React + TypeScript. useContext
import { createContext, useContext, useState, ReactNode } from "react";
type Theme = "light" | "dark";
type ThemeContextType = {
theme: Theme;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | null>(null);
type ThemeProviderProps = {
children: ReactNode;
};
function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return context;
}
React + TypeScript. useReducer
import { useReducer } from "react";
type CounterState = {
count: number;
};
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
const initialState: CounterState = {
count: 0,
};
function reducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return initialState;
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
Тут дуже добре видно користь union типів для action.
Корисні вбудовані TypeScript та React-патерни
-
Partial<T>— робить усі поля необов’язковими -
Pick<T, K>— бере лише вибрані поля -
Omit<T, K>— виключає вибрані поля -
Record<K, V>— об’єкт з ключами одного типу та значеннями іншого ReactNode— для children-
ComponentProps<"button">— типізація нативних props HTML-елемента
Partial
type User = {
id: number;
name: string;
email: string;
};
type UserUpdate = Partial<User>;
const updateData: UserUpdate = {
name: "New name",
};
Pick
type UserPreview = Pick<User, "id" | "name">;
Omit
type PublicUser = Omit<User, "email">;
ComponentProps для button
import { ComponentProps } from "react";
type CustomButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "secondary";
};
function CustomButton({
variant = "primary",
children,
...props
}: CustomButtonProps) {
return (
<button {...props}>
{children} - {variant}
</button>
);
}
Це дуже зручний підхід, коли ти робиш власну обгортку над
нативним
button.
Типізація компонентів: практичні поради
- Починай із типізації props і state
- Окремо описуй типи для API-даних
- Не поспішай ставити
any - Для форм завжди типізуй submit та change events
- Для списків створюй окремі типи елементів
- Для callback props завжди явно описуй параметри
-
Для nullable state використовуй
T | null
Типові помилки новачків у React + TypeScript
- Занадто часто використовують
any - Не типізують props
- Не вказують тип для
useState(null) -
Плутають
event.targetіevent.currentTarget - Не описують типи даних з API
- Пишуть один гігантський тип замість кількох маленьких
- Не виносять типи в окремі файли там, де це вже потрібно
Структура типів у проєкті
На невеликому проєкті типи можна тримати поруч із компонентами.
src/
components/
UserCard/
UserCard.tsx
UserCard.types.ts
types/
api.ts
user.ts
post.ts
На більших проєктах часто виділяють окрему папку
types або shared/types.
TypeScript з React у Vite
Vite — один із найзручніших способів швидко стартувати React + TypeScript проєкт.
Створення проєкту Vite + React + TypeScript
npm create vite@latest my-app
cd my-app
npm install
npm run dev
Під час створення обери React і TypeScript шаблон.
Типова структура Vite + React + TS
src/
components/
App.tsx
main.tsx
index.html
tsconfig.json
vite.config.ts
main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Знак ! після
getElementById("root") означає, що ми впевнені:
елемент точно існує.
App.tsx
function App() {
return (
<main>
<h1>React + TypeScript + Vite</h1>
</main>
);
}
export default App;
Команди для Vite
npm run dev
npm run build
npm run preview
Де писати типи у Vite-проєкті
- поруч із компонентом, якщо тип локальний
-
в окремому файлі
types.ts, якщо тип використовують у кількох місцях - в
src/types, якщо типів уже багато
env змінні у Vite
VITE_API_URL=https://api.example.com
const apiUrl = import.meta.env.VITE_API_URL;
У Vite змінні середовища для клієнтського коду мають
починатися з
VITE_.
TypeScript з React у Next.js
Next.js теж дуже добре працює з TypeScript. Залежно від структури проєкту ти можеш використовувати або App Router, або Pages Router.
Створення Next.js + TypeScript проєкту
npx create-next-app@latest my-next-app
Під час створення проєкту обери TypeScript, ESLint та інші потрібні опції.
Приблизна структура Next.js App Router + TypeScript
app/
page.tsx
layout.tsx
components/
types/
next.config.ts
tsconfig.json
page.tsx в App Router
export default function HomePage() {
return (
<main>
<h1>Next.js + TypeScript</h1>
</main>
);
}
Client Component у Next.js
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Якщо компонент використовує state, effects або обробники
подій у Next.js App Router, зазвичай потрібна директива
"use client".
Props у Next.js App Router
type UserCardProps = {
name: string;
age: number;
};
export default function UserCard({ name, age }: UserCardProps) {
return (
<div>
<h3>{name}</h3>
<p>Age: {age}</p>
</div>
);
}
Pages Router + TypeScript
pages/
index.tsx
about.tsx
components/
types/
export default function HomePage() {
return <h1>Pages Router + TypeScript</h1>;
}
Головна різниця між Vite і Next.js у темі TypeScript
- У Vite ти створюєш звичайний React SPA або CSR-проєкт
- У Next.js TypeScript працює і в клієнтських, і в серверних частинах
- У Next.js є App Router і Pages Router
- У Next.js є серверні компоненти, route handlers, серверні функції
- У Vite налаштування простіше для навчання “чистого React”
env змінні у Next.js
NEXT_PUBLIC_API_URL=https://api.example.com
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
Для клієнтського коду в Next.js змінна повинна починатися з
NEXT_PUBLIC_.
Практичний приклад React + TypeScript компонента
import { ChangeEvent, FormEvent, useState } from "react";
type ContactFormData = {
name: string;
email: string;
message: string;
};
export default function ContactForm() {
const [formData, setFormData] = useState<ContactFormData>({
name: "",
email: "",
message: "",
});
const [isSent, setIsSent] = useState(false);
const handleChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = event.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(formData);
setIsSent(true);
};
return (
<section>
<form onSubmit={handleSubmit}>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
/>
<button type="submit">Send</button>
</form>
{isSent && <p>Message sent</p>}
</section>
);
}
Тут одночасно використані props-free компонент, state, form
events, union для події input | textarea і
базовий submit flow.
Що створювати і де створювати
Для невеликого React + TypeScript проєкту
components/— компонентиtypes/— спільні типиservices/— робота з APIhooks/— кастомні хукиutils/— допоміжні функції
Приклад структури
src/
components/
UserCard.tsx
UserList.tsx
ContactForm.tsx
hooks/
useUsers.ts
services/
userService.ts
types/
user.ts
post.ts
utils/
formatDate.ts
App.tsx
Cheat Sheet — коротка шпаргалка
type User = {
id: number;
name: string;
};
interface Product {
id: number;
title: string;
}
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<User[]>([]);
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
console.log(event.target.value);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
};
const inputRef = useRef<HTMLInputElement | null>(null);
Що варто запам’ятати в першу чергу
-
Props типізуються через
typeабоinterface -
useStateчасто сам виводить тип, але не завжди -
Для
nullстанів використовуйT | null - Події типізуються через типи React
- Для children використовуй
ReactNode - Для API-даних створюй окремі типи
- Не зловживай
any
Фінальний висновок
Якщо ти вивчаєш React серйозно, TypeScript дуже бажано освоїти хоча б на рівні props, state, events, API-даних і базових generics.
Для практики найкраще почати з простих компонентів:
- кнопка з типізованими props
- форма з типізованими events
- список користувачів з типізованим масивом
- fetch даних з типізованою відповіддю
-
компонент з
useRefіuseContext
Найкращий шлях: спочатку навчитися типізувати звичайний TS-код, потім props, потім state, потім events, і лише після цього переходити до складніших тем на кшталт generics, reducers і контексту.
Refs, Higher-Order Component, DefaultProps — повний гайд для trainee
У React є декілька важливих тем, які часто зустрічаються в реальних проєктах: refs, Higher-Order Components (HOC) та default props.
Refs потрібні для доступу до DOM-елементів або збереження значень між рендерами без повторного рендеру. HOC дозволяють перевикористовувати логіку між компонентами. Default props допомагають задавати значення за замовчуванням для пропсів.
Для trainee-рівня важливо не просто знати синтаксис, а розуміти: коли це використовувати, де створювати, як організовувати код і яких помилок уникати.
Що треба вивчити по цій темі
- Що таке ref у React
- Різниця між useRef і state
- Доступ до DOM через ref
- Фокус на input через ref
- Збереження значення між рендерами без useState
- forwardRef
- useImperativeHandle
- Що таке Higher-Order Component
- Як працює патерн component in, component out
- Як передавати props через HOC
- Як додавати логіку через HOC
- Як не ламати displayName
- Що таке defaultProps
- Коли краще використовувати default parameters замість defaultProps
- Різниця між Vite і Next.js у використанні цих підходів
1. Refs у React
Ref — це спеціальний об’єкт, який дозволяє зберігати посилання на DOM-елемент або будь-яке значення, яке повинно переживати повторні рендери компонента.
Найчастіше refs використовують для:
- встановлення фокусу на input
- доступу до DOM-елемента
- роботи зі scroll
- збереження таймерів, previous values, id
- уникнення зайвих ререндерів для технічних значень
Що повертає useRef
Хук useRef() повертає об’єкт такого вигляду:
const refObject = {
current: null
};
Усі дані зберігаються в полі current.
Базовий приклад useRef
import { useRef } from "react";
function Example() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} type="text" placeholder="Введіть текст" />
<button onClick={handleClick}>Focus input</button>
</div>
);
}
export default Example;
Тут inputRef прив’язується до DOM-елемента
input через атрибут ref. Після цього в
inputRef.current буде доступний сам input.
Як це працює покроково
- Створюється ref через
useRef(null). -
Ref передається в JSX через
ref={inputRef}. -
Після рендеру React записує DOM-елемент у
inputRef.current. -
У функції
handleClickможна викликати DOM-методfocus().
Refs і state — у чому різниця
import { useRef, useState } from "react";
function CounterExample() {
const renderCountRef = useRef(0);
const [count, setCount] = useState(0);
renderCountRef.current += 1;
return (
<div>
<p>State count: {count}</p>
<p>Render count: {renderCountRef.current}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
export default CounterExample;
useState змінює стан і викликає повторний
рендер. useRef зберігає значення між рендерами,
але не викликає новий рендер сам по собі.
Тобто:
state— для даних, які впливають на UI-
ref— для технічних значень або доступу до DOM
Коли useRef підходить ідеально
import { useEffect, useRef } from "react";
function TimerExample() {
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setTimeout(() => {
console.log("Таймер завершився");
}, 2000);
return () => {
clearTimeout(timerRef.current);
};
}, []);
return <p>Перевір консоль</p>;
}
export default TimerExample;
Таймер не треба зберігати в state, бо він не відображається в UI. Для цього ідеально підходить ref.
Збереження попереднього значення через ref
import { useEffect, useRef, useState } from "react";
function PreviousValueExample() {
const [value, setValue] = useState("");
const previousValueRef = useRef("");
useEffect(() => {
previousValueRef.current = value;
}, [value]);
return (
<div>
<input
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<p>Current value: {value}</p>
<p>Previous value: {previousValueRef.current}</p>
</div>
);
}
export default PreviousValueExample;
Це популярний сценарій: зберігати попереднє значення без окремого state.
forwardRef — передача ref у дочірній компонент
За замовчуванням ref не передається в кастомний компонент як
звичайний prop. Для цього потрібен forwardRef.
import { forwardRef, useRef } from "react";
const CustomInput = forwardRef(function CustomInput(props, ref) {
return <input ref={ref} type="text" placeholder={props.placeholder} />;
});
function App() {
const inputRef = useRef(null);
function handleFocus() {
inputRef.current.focus();
}
return (
<div>
<CustomInput ref={inputRef} placeholder="Ваше ім'я" />
<button onClick={handleFocus}>Focus custom input</button>
</div>
);
}
export default App;
Тут ref передається не напряму через props, а через другий
аргумент функції, обгорнутої в forwardRef.
useImperativeHandle — контроль того, що бачить батько
Іноді не хочеться віддавати батьківському компоненту весь DOM-елемент. Можна віддати лише певні методи.
import {
forwardRef,
useImperativeHandle,
useRef
} from "react";
const CustomInput = forwardRef(function CustomInput(props, ref) {
const innerInputRef = useRef(null);
useImperativeHandle(ref, () => {
return {
focusInput() {
innerInputRef.current.focus();
},
clearInput() {
innerInputRef.current.value = "";
}
};
});
return <input ref={innerInputRef} type="text" />;
});
function App() {
const customInputRef = useRef(null);
function handleFocus() {
customInputRef.current.focusInput();
}
function handleClear() {
customInputRef.current.clearInput();
}
return (
<div>
<CustomInput ref={customInputRef} />
<button onClick={handleFocus}>Focus</button>
<button onClick={handleClear}>Clear</button>
</div>
);
}
export default App;
Це вже більш просунутий кейс, але його корисно знати. Батьківський компонент бачить не весь input, а тільки методи, які ти явно відкрив.
Типові помилки при роботі з refs
-
Спроба читати
ref.currentдо того, як елемент змонтований - Використання ref там, де краще підійде state
- Занадто активна робота з DOM замість React-підходу
-
Забування про
forwardRefу кастомних компонентах - Змішування бізнес-логіки та технічної логіки в одному ref
2. Higher-Order Component (HOC)
Higher-Order Component — це функція, яка приймає компонент і повертає новий компонент.
Простими словами: HOC — це спосіб загорнути компонент додатковою логікою, не змінюючи сам компонент напряму.
Формула HOC
const EnhancedComponent = withSomething(BaseComponent);
Де:
withSomething— HOCBaseComponent— звичайний компонент-
EnhancedComponent— новий компонент з додатковою логікою
Найпростіший приклад HOC
function withBorder(Component) {
return function WrappedComponent(props) {
return (
<div style={{ border: "2px solid black", padding: "12px" }}>
<Component {...props} />
</div>
);
};
}
function Message(props) {
return <p>Hello, {props.name}!</p>;
}
const MessageWithBorder = withBorder(Message);
export default MessageWithBorder;
Тут HOC withBorder додає обгортку навколо
будь-якого компонента.
Що тут важливо зрозуміти
HOC не змінює оригінальний компонент. Він створює новий компонент, який рендерить старий компонент і додає зверху якусь логіку, розмітку або props.
HOC для перевірки авторизації
function withAuth(Component) {
return function ProtectedComponent(props) {
const isAuthenticated = true;
if (!isAuthenticated) {
return <p>Access denied</p>;
}
return <Component {...props} />;
};
}
function Profile(props) {
return <h2>Welcome, {props.username}</h2>;
}
const ProtectedProfile = withAuth(Profile);
export default ProtectedProfile;
Це типовий приклад: HOC вирішує, чи показувати компонент, чи ні.
HOC для додавання loading-логіки
function withLoading(Component) {
return function WrappedComponent(props) {
if (props.isLoading) {
return <p>Loading...</p>;
}
return <Component {...props} />;
};
}
function UserList(props) {
return (
<ul>
{props.users.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
const UserListWithLoading = withLoading(UserList);
export default UserListWithLoading;
Тут HOC бере на себе відображення стану завантаження, а базовий компонент відповідає тільки за список.
Передача props через HOC
Дуже важливо не забувати передавати пропси далі:
<Component {...props} />
Якщо цього не зробити, компонент втратить доступ до пропсів, які йому передали зовні.
HOC з додатковими props
function withUser(Component) {
return function WrappedComponent(props) {
const user = {
id: 1,
name: "Viktor",
role: "student"
};
return <Component {...props} user={user} />;
};
}
function Dashboard(props) {
return (
<div>
<h2>Dashboard</h2>
<p>User: {props.user.name}</p>
<p>Role: {props.user.role}</p>
</div>
);
}
const DashboardWithUser = withUser(Dashboard);
export default DashboardWithUser;
HOC може не лише обгорнути компонент, а й додати в нього нові props.
displayName у HOC
Для зручності дебагу бажано задавати
displayName, щоб у React DevTools було видно
зрозумілу назву компонента.
function withLogger(Component) {
function WrappedComponent(props) {
console.log("Props:", props);
return <Component {...props} />;
}
const componentName = Component.displayName || Component.name || "Component";
WrappedComponent.displayName = "withLogger(" + componentName + ")";
return WrappedComponent;
}
Практичний приклад HOC з логуванням
function withLogger(Component) {
return function WrappedComponent(props) {
console.log("Компонент рендериться з props:", props);
return <Component {...props} />;
};
}
function ProductCard(props) {
return (
<div>
<h3>{props.title}</h3>
<p>{props.price} USD</p>
</div>
);
}
const ProductCardWithLogger = withLogger(ProductCard);
export default ProductCardWithLogger;
Коли HOC варто використовувати
- коли треба повторно використати однакову логіку для кількох компонентів
- коли треба додати перевірку доступу
- коли треба додати технічну обгортку
- коли проєкт уже використовує HOC і треба підтримувати існуючий стиль
Але в сучасному React часто замість HOC використовують кастомні хуки або render props. Проте HOC все ще важливо розуміти, бо вони часто є в старому коді або бібліотеках.
Типові помилки з HOC
- не передають
...props - створюють HOC усередині компонента, через що він перевизначається на кожному рендері
- зашивають у HOC занадто багато логіки
- не задають
displayName - плутають HOC зі звичайною обгорткою JSX
3. DefaultProps
Default props — це значення за замовчуванням для пропсів компонента.
Вони використовуються тоді, коли батьківський компонент не передав якийсь prop.
Базовий приклад defaultProps
function Button(props) {
return <button>{props.text}</button>;
}
Button.defaultProps = {
text: "Click me"
};
export default Button;
Якщо компонент викликати так:
<Button />
то він відрендерить текст Click me.
Приклад з кількома значеннями
function UserCard(props) {
return (
<div>
<h3>{props.name}</h3>
<p>Age: {props.age}</p>
<p>Role: {props.role}</p>
</div>
);
}
UserCard.defaultProps = {
name: "Anonymous",
age: 18,
role: "Student"
};
export default UserCard;
Що важливо розуміти про defaultProps
Значення з defaultProps використовуються лише
тоді, коли prop дорівнює undefined.
Якщо передати:
<UserCard name={null} />
то null не буде замінений на значення з
defaultProps. React вважатиме, що значення
передали явно.
Сучасний підхід: default parameters
Для функціональних компонентів сьогодні часто використовують
не
defaultProps, а значення за замовчуванням прямо
в аргументах.
function Button({ text = "Click me", type = "button" }) {
return <button type={type}>{text}</button>;
}
export default Button;
Це коротше, читабельніше і дуже популярно в сучасному React-коді.
Що краще: defaultProps чи default parameters
-
defaultProps— часто зустрічаються в старішому коді -
default parameters— частіше використовуються в сучасних функціональних компонентах -
для class components історично частіше використовували
саме
defaultProps
Приклад з class component
import React, { Component } from "react";
class Greeting extends Component {
render() {
return <h2>Hello, {this.props.name}!</h2>;
}
}
Greeting.defaultProps = {
name: "Guest"
};
export default Greeting;
Default props + destructuring
function ProfileCard({
name = "Unknown user",
city = "Unknown city",
isOnline = false
}) {
return (
<div>
<h3>{name}</h3>
<p>City: {city}</p>
<p>Status: {isOnline ? "Online" : "Offline"}</p>
</div>
);
}
export default ProfileCard;
Це один із найзручніших і найчистіших варіантів для trainee-рівня.
Типові помилки з default props
- плутають
undefinedіnull - змішують кілька підходів без потреби
- ставлять default значення занадто глибоко в коді
- використовують defaultProps там, де простіше зробити значення в параметрах функції
4. Refs, HOC, DefaultProps у Vite
У Vite ці теми використовуються стандартно, без спеціальної магії.
Якщо це звичайний React-проєкт на Vite, то refs, HOC і default props працюють так само, як у звичайному React.
Створення Vite-проєкту
npm create vite@latest my-react-app
cd my-react-app
npm install
npm run dev
Приклад структури файлів у Vite
src/
components/
FocusInput.jsx
UserCard.jsx
ProductCard.jsx
hoc/
withLoading.jsx
withAuth.jsx
App.jsx
main.jsx
Refs у Vite
// src/components/FocusInput.jsx
import { useRef } from "react";
function FocusInput() {
const inputRef = useRef(null);
function handleFocus() {
inputRef.current.focus();
}
return (
<section>
<input ref={inputRef} type="text" placeholder="Type here" />
<button onClick={handleFocus}>Focus</button>
</section>
);
}
export default FocusInput;
HOC у Vite
// src/hoc/withLoading.jsx
function withLoading(Component) {
return function WrappedComponent(props) {
if (props.isLoading) {
return <p>Loading...</p>;
}
return <Component {...props} />;
};
}
export default withLoading;
// src/components/ProductList.jsx
function ProductList(props) {
return (
<ul>
{props.items.map(function(item) {
return <li key={item.id}>{item.title}</li>;
})}
</ul>
);
}
export default ProductList;
// src/App.jsx
import ProductList from "./components/ProductList";
import withLoading from "./hoc/withLoading";
const ProductListWithLoading = withLoading(ProductList);
function App() {
const items = [
{ id: 1, title: "React" },
{ id: 2, title: "Vite" }
];
return <ProductListWithLoading isLoading={false} items={items} />;
}
export default App;
Default props у Vite
// src/components/UserCard.jsx
function UserCard({ name = "Guest", role = "Student" }) {
return (
<div>
<h3>{name}</h3>
<p>{role}</p>
</div>
);
}
export default UserCard;
5. Refs, HOC, DefaultProps у Next.js
У Next.js ці концепції також працюють майже так само, але треба враховувати різницю між Server Components і Client Components.
Головне правило:
-
useRefможна використовувати тільки в Client Component - обробники подій теж працюють тільки в Client Component
- HOC, які використовують хуки або браузерний API, теж мають бути в Client Component
Створення Next.js-проєкту
npx create-next-app@latest my-next-app
cd my-next-app
npm run dev
Структура файлів у Next.js App Router
app/
page.jsx
components/
FocusInput.jsx
UserCard.jsx
hoc/
withLoading.jsx
Refs у Next.js
Через те, що useRef — це клієнтський хук, файл
повинен мати директиву "use client";.
"use client";
import { useRef } from "react";
function FocusInput() {
const inputRef = useRef(null);
function handleFocus() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} type="text" placeholder="Type here" />
<button onClick={handleFocus}>Focus</button>
</div>
);
}
export default FocusInput;
Використання в page.jsx
import FocusInput from "@/components/FocusInput";
export default function HomePage() {
return (
<main>
<h1>Refs in Next.js</h1>
<FocusInput />
</main>
);
}
HOC у Next.js
Якщо HOC не використовує хуки, він може бути простим модулем. Але якщо всередині є клієнтська логіка, краще вважати його клієнтським.
"use client";
function withLoading(Component) {
return function WrappedComponent(props) {
if (props.isLoading) {
return <p>Loading...</p>;
}
return <Component {...props} />;
};
}
export default withLoading;
Default props у Next.js
function UserCard({ name = "Guest", role = "Student" }) {
return (
<section>
<h2>{name}</h2>
<p>Role: {role}</p>
</section>
);
}
export default UserCard;
Для default props у Next.js окремих налаштувань не потрібно.
Головна різниця між Vite і Next.js
- у Vite майже всі компоненти клієнтські за замовчуванням
- у Next.js App Router компоненти за замовчуванням серверні
-
для
useRef, подій і браузерного API у Next.js треба"use client"; - HOC і default props самі по собі працюють однаково, але середовище виконання в Next.js важливе
6. Порівняння: коли що використовувати
Коли використовувати refs
- фокус на input
- scroll до елемента
- робота з таймерами
- збереження previous value
- доступ до DOM, коли це реально потрібно
Коли використовувати HOC
- для повторного використання логіки між кількома компонентами
- у старих кодових базах, де HOC уже активно застосовуються
- для технічних обгорток: loading, auth, logger
Коли використовувати default props
- коли компонент має безпечні значення за замовчуванням
- коли хочеш зробити API компонента зручнішим
- коли не всі props обов’язкові
7. Типовий mini-проєкт з усіма трьома темами
import { useRef } from "react";
function withBorder(Component) {
return function WrappedComponent(props) {
return (
<div style={{ border: "1px solid gray", padding: "12px" }}>
<Component {...props} />
</div>
);
};
}
function SearchInput({ placeholder = "Search..." }) {
const inputRef = useRef(null);
function handleFocus() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} type="text" placeholder={placeholder} />
<button onClick={handleFocus}>Focus input</button>
</div>
);
}
const SearchInputWithBorder = withBorder(SearchInput);
export default SearchInputWithBorder;
У цьому прикладі:
-
useRefвикористовується для доступу до input withBorder— це HOC-
placeholder = "Search..."— це default value для prop
8. Команди, які корисно пам’ятати
Vite
npm create vite@latest my-app
cd my-app
npm install
npm run dev
Next.js
npx create-next-app@latest my-app
cd my-app
npm run dev
Створення компонентів вручну
src/components/FocusInput.jsx
src/hoc/withLoading.jsx
src/components/UserCard.jsx
9. Найважливіші правила для trainee
- Не використовуй ref замість state без причини
- Не працюй напряму з DOM, якщо задачу можна вирішити React-способом
- У HOC завжди прокидай
...props - Для функціональних компонентів частіше використовуй default values у параметрах
-
У Next.js пам’ятай про
"use client";дляuseRefі подій - Не ускладнюй HOC, якщо можна зробити простіше через кастомний хук
10. Типові питання на співбесіді або в навчанні
- Що таке useRef і коли він потрібен?
- Чим useRef відрізняється від useState?
- Що таке forwardRef?
- Що таке Higher-Order Component?
- Навіщо в HOC прокидати props?
- Що таке defaultProps?
- Чим defaultProps відрізняються від default parameters?
- Чому в Next.js для useRef потрібен Client Component?
11. Короткий підсумок
Refs — це інструмент для доступу до DOM і збереження значень між рендерами без повторного рендеру.
HOC — це патерн для повторного використання логіки через обгортання компонентів.
Default props — це спосіб задавати значення за замовчуванням для props.
У Vite все працює майже прямо з коробки. У Next.js треба враховувати межу між серверними і клієнтськими компонентами.
Якщо добре зрозуміти ці три теми, буде значно легше читати як сучасний React-код, так і старіші проєкти.
Composition. Context. Optimization (useMemo, useCallback, react-virtualized) — повний гайд для trainee
Ця сторінка — практична шпаргалка по трьох дуже важливих темах React: Composition, Context та Optimization.
Якщо коротко, Composition допомагає будувати гнучкі компоненти з дрібних частин, Context дає змогу передавати дані без prop drilling, а Optimization потрібна для того, щоб застосунок не робив зайву роботу під час ререндерів.
Окремо розберемо useMemo, useCallback і react-virtualized, а також подивимось, як усе це використовувати у Vite та Next.js.
Що саме треба розуміти по цій темі
- Що таке composition у React і чому він важливіший за “великий універсальний компонент”
- Children, slots-подібний підхід, wrapper-компоненти
- Controlled composition і передача компонентів через props
- Що таке Context API і коли його реально варто використовувати
- Чим Context відрізняється від звичайних props
- Що таке prop drilling
- Що таке зайві ререндери
- Як працюють useMemo та useCallback
- Чому memo, useMemo і useCallback часто плутають
- Коли оптимізація корисна, а коли вона тільки ускладнює код
- Що таке virtualization списків
- Навіщо потрібен react-virtualized
- Які є відмінності між Vite та Next.js при роботі з цими інструментами
1. Composition у React
Composition — це підхід, коли великий інтерфейс складається з маленьких, перевикористовуваних компонентів.
У React це один з найважливіших принципів. Замість того щоб створювати один “монстр-компонент” на 500 рядків, краще розбити логіку на менші частини.
Composition робить код читабельнішим, простішим для тестування і зручнішим для повторного використання.
Навіщо потрібен composition
- Зменшує розмір одного компонента
- Спрощує підтримку
- Дає можливість перевикористовувати UI
- Полегшує тестування
- Дозволяє будувати гнучкі API для компонентів
Найпростіший приклад composition
function Page() {
return (
<main>
<Header />
<Sidebar />
<Content />
<Footer />
</main>
);
}
Тут Page не містить усю логіку в собі. Він
просто складається з інших компонентів.
Composition через children
Один із найчастіших способів композиції — використання
children.
function Card({ children }) {
return <div className="card">{children}</div>;
}
function App() {
return (
<Card>
<h2>User profile</h2>
<p>Front-end developer</p>
</Card>
);
}
Компонент Card не знає, що саме буде всередині.
Це робить його універсальним.
Composition через кілька “slot”-областей
React не має вбудованих slots як деякі інші фреймворки, але їх легко імітувати через props.
function Layout({ header, sidebar, content, footer }) {
return (
<div>
<header>{header}</header>
<aside>{sidebar}</aside>
<section>{content}</section>
<footer>{footer}</footer>
</div>
);
}
function App() {
return (
<Layout
header={<h1>Dashboard</h1>}
sidebar={<nav>Menu</nav>}
content={<div>Main content</div>}
footer={<small>2026</small>}
/>
);
}
Такий підхід корисний, коли в компонента є кілька незалежних зон.
Wrapper-компонент
function Modal({ children, title }) {
return (
<div className="modal-backdrop">
<div className="modal">
<h2>{title}</h2>
<div>{children}</div>
</div>
</div>
);
}
function App() {
return (
<Modal title="Delete item">
<p>Are you sure?</p>
<button>Confirm</button>
</Modal>
);
}
Wrapper-компонент задає загальну структуру і стилі, а внутрішній контент передається ззовні.
Передача компонента через props
function Button({ component: Component = "button", children, ...rest }) {
return <Component {...rest}>{children}</Component>;
}
function App() {
return (
<div>
<Button>Regular button</Button>
<Button component="a" href="/profile">
Link button
</Button>
</div>
);
}
Це дає ще більше гнучкості компоненту.
Поганий і хороший підхід
Погано — один компонент робить все:
function HugeProfileCard() {
return (
<div>
<img src="/avatar.png" alt="Avatar" />
<h2>Viktor</h2>
<p>Front-end developer</p>
<button>Follow</button>
<button>Message</button>
</div>
);
}
Краще — розбити на складові:
function Avatar({ src, alt }) {
return <img src={src} alt={alt} />;
}
function UserInfo({ name, role }) {
return (
<div>
<h2>{name}</h2>
<p>{role}</p>
</div>
);
}
function UserActions() {
return (
<div>
<button>Follow</button>
<button>Message</button>
</div>
);
}
function ProfileCard() {
return (
<div>
<Avatar src="/avatar.png" alt="Avatar" />
<UserInfo name="Viktor" role="Front-end developer" />
<UserActions />
</div>
);
}
Коли composition особливо корисний
- Card, Modal, Layout, Tabs, Accordion, Dropdown
- UI-бібліотеки та дизайн-системи
- Форми з повторюваними блоками
- Сторінки з великою кількістю секцій
2. Context API
Context потрібен для передачі даних крізь дерево компонентів без ручного прокидування props на кожному рівні.
Найчастіше через Context передають тему, мову, авторизацію, налаштування інтерфейсу або глобальні дані невеликого масштабу.
Проблема prop drilling
function App() {
const theme = "dark";
return <Page theme={theme} />;
}
function Page({ theme }) {
return <Sidebar theme={theme} />;
}
function Sidebar({ theme }) {
return <Profile theme={theme} />;
}
function Profile({ theme }) {
return <p>Current theme: {theme}</p>;
}
Якщо значення потрібне тільки глибокому дочірньому компоненту, а всі проміжні компоненти просто “перекидають” його далі, це prop drilling.
Базове створення context
import { createContext, useContext } from "react";
const ThemeContext = createContext("light");
function App() {
return (
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
);
}
function Page() {
return <Profile />;
}
function Profile() {
const theme = useContext(ThemeContext);
return <p>Current theme: {theme}</p>;
}
Що тут відбувається
createContext()створює контекст-
Providerпередає значення вниз по дереву -
useContext()читає значення в дочірньому компоненті
Більш реальний приклад: ThemeProvider
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme(function(prevTheme) {
return prevTheme === "light" ? "dark" : "light";
});
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
Використання кастомного хука
import { ThemeProvider, useTheme } from "./theme-context";
function ThemeSwitcher() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle theme</button>
</div>
);
}
function App() {
return (
<ThemeProvider>
<ThemeSwitcher />
</ThemeProvider>
);
}
Такий підхід зручний, бо ти не імпортуєш сам context у кожному компоненті, а користуєшся акуратним кастомним хуком.
Коли Context — хороший вибір
- Тема оформлення
- Поточна мова
- Інформація про користувача
- Статус авторизації
- Налаштування UI
Коли Context — поганий вибір
- Дуже часто оновлювані дані великого обсягу
- Складний глобальний стан зі складною бізнес-логікою
- Ситуації, де краще підійде Redux, Zustand або інше state management рішення
Чому Context може спричиняти зайві ререндери
Коли значення у Provider змінюється, усі компоненти, які читають цей context, можуть перерендеритися.
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<AuthContext.Provider
value={{ user, setUser, isModalOpen, setIsModalOpen }}
>
{children}
</AuthContext.Provider>
);
}
Тут навіть зміна isModalOpen може змусити
оновитися компоненти, яким потрібен лише user.
Як покращити Context
- Розділяти великий context на кілька менших
- Мемоізувати value, якщо це доречно
- Не класти в один context усе підряд
- Виносити локальний стан ближче до місця використання
Приклад з useMemo для value у Provider
import { createContext, useMemo, useState } from "react";
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(
function() {
return { user, setUser };
},
[user]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
Це корисно, коли об’єкт передається вниз і ти хочеш уникнути створення нового об’єкта на кожному рендері без потреби.
Корисний захист для кастомного hook
import { createContext, useContext } from "react";
const ThemeContext = createContext(null);
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return context;
}
Це допомагає швидше знайти помилку, якщо компонент використали поза Provider.
3. Optimization у React
Optimization у React — це не “додати useMemo всюди”. Це вміння прибирати зайву роботу лише там, де вона реально є.
Спочатку варто написати зрозумілий код, а вже потім оптимізувати вузькі місця.
Що найчастіше викликає проблеми з продуктивністю
- Зайві ререндери
- Важкі обчислення в тілі компонента
- Великі списки
- Створення нових функцій та об’єктів без потреби
- Неправильно організований Context
Що треба знати перед useMemo і useCallback
React.memoмемоізує сам компонентuseMemoмемоізує значенняuseCallbackмемоізує функцію
4. useMemo
useMemo використовують для кешування результату
обчислення між рендерами.
Він корисний, коли обчислення дороге або коли потрібно зберегти стабільне посилання на масив чи об’єкт.
Синтаксис useMemo
const cachedValue = useMemo(
function() {
return someExpensiveCalculation(data);
},
[data]
);
Простий приклад без useMemo
function ProductList({ products, search }) {
const filteredProducts = products.filter(function(product) {
return product.title.toLowerCase().includes(search.toLowerCase());
});
return (
<ul>
{filteredProducts.map(function(product) {
return <li key={product.id}>{product.title}</li>;
})}
</ul>
);
}
На кожному рендері список буде фільтруватися заново.
Той самий приклад з useMemo
import { useMemo } from "react";
function ProductList({ products, search }) {
const filteredProducts = useMemo(
function() {
return products.filter(function(product) {
return product.title.toLowerCase().includes(search.toLowerCase());
});
},
[products, search]
);
return (
<ul>
{filteredProducts.map(function(product) {
return <li key={product.id}>{product.title}</li>;
})}
</ul>
);
}
Тепер фільтрація виконається лише тоді, коли зміняться
products або search.
useMemo для стабільного об’єкта
function Parent({ user }) {
const userInfo = useMemo(
function() {
return {
fullName: user.firstName + " " + user.lastName,
role: user.role
};
},
[user.firstName, user.lastName, user.role]
);
return <Child userInfo={userInfo} />;
}
Це буває корисно, коли дочірній компонент обгорнутий у
React.memo.
Типові помилки з useMemo
- Використовувати його для будь-якого дрібного значення
- Забувати залежності
- Робити через нього side effects
- Оптимізувати те, що не є вузьким місцем
Коли useMemo реально корисний
- Сортування великих масивів
- Фільтрація великих списків
- Складні обчислення
- Створення стабільного значення для memo-компонентів
5. useCallback
useCallback кешує функцію між рендерами.
Найчастіше він потрібен не сам по собі, а разом із
React.memo, щоб дочірній компонент не отримував
щоразу нову функцію і не ререндерився без потреби.
Синтаксис useCallback
const memoizedFunction = useCallback(
function() {
doSomething();
},
[dependency]
);
Проблема без useCallback
import { memo, useState } from "react";
const Button = memo(function Button({ onClick, children }) {
console.log("Button render");
return <button onClick={onClick}>{children}</button>;
});
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log("clicked");
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<Button onClick={handleClick}>Click me</Button>
</div>
);
}
При кожному рендері App створюється нова
функція handleClick, і навіть memo-компонент
може ререндеритися.
Рішення з useCallback
import { memo, useCallback, useState } from "react";
const Button = memo(function Button({ onClick, children }) {
console.log("Button render");
return <button onClick={onClick}>{children}</button>;
});
function App() {
const [count, setCount] = useState(0);
const handleClick = useCallback(function() {
console.log("clicked");
}, []);
const increment = useCallback(function() {
setCount(function(prevCount) {
return prevCount + 1;
});
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<Button onClick={handleClick}>Click me</Button>
</div>
);
}
Чому тут використаний callback setState
setCount(function(prevCount) {
return prevCount + 1;
});
Це дозволяє не додавати count у залежності і
уникати зайвого перевизначення функції.
Коли useCallback корисний
- Коли функція передається в memo-компонент
- Коли функція використовується в залежностях іншого hook
- Коли реально є проблема з рендерами
Коли useCallback не потрібен
- Для кожної дрібної функції “про всяк випадок”
- Якщо дочірні компоненти все одно ререндеряться з інших причин
- Якщо немає жодної вимірюваної проблеми
6. React.memo + useMemo + useCallback разом
import { memo, useCallback, useMemo, useState } from "react";
const UserList = memo(function UserList({ users, onSelect }) {
console.log("UserList render");
return (
<ul>
{users.map(function(user) {
return (
<li key={user.id}>
<button onClick={() => onSelect(user)}>{user.name}</button>
</li>
);
})}
</ul>
);
});
function App({ users }) {
const [search, setSearch] = useState("");
const filteredUsers = useMemo(
function() {
return users.filter(function(user) {
return user.name.toLowerCase().includes(search.toLowerCase());
});
},
[users, search]
);
const handleSelect = useCallback(function(user) {
console.log("Selected user:", user.name);
}, []);
return (
<div>
<input
value={search}
onChange={function(event) {
setSearch(event.target.value);
}}
placeholder="Search user"
/>
<UserList users={filteredUsers} onSelect={handleSelect} />
</div>
);
}
Тут:
useMemoкешує відфільтрований списокuseCallbackкешує обробник-
memoдопомагає дочірньому компоненту не ререндеритися без потреби
7. react-virtualized
Якщо список дуже великий, навіть правильний useMemo не вирішить проблему повністю.
Якщо ти рендериш 10 000 елементів у DOM, браузеру все одно буде важко. Саме тут допомагає virtualization.
Ідея проста: на екрані відображаються тільки видимі елементи плюс невеликий запас, а не весь список одразу.
Коли потрібен react-virtualized
- Дуже великі списки
- Таблиці з великою кількістю рядків
- Логи, чати, історії змін
- Каталоги товарів з тисячами елементів
Встановлення у Vite або Next.js
npm install react-virtualized
Базовий приклад List
import { List } from "react-virtualized";
import "react-virtualized/styles.css";
const list = Array.from({ length: 1000 }, function(_, index) {
return "Row " + index;
});
function rowRenderer({ index, key, style }) {
return (
<div key={key} style={style}>
{list[index]}
</div>
);
}
function App() {
return (
<List
width={400}
height={300}
rowCount={list.length}
rowHeight={40}
rowRenderer={rowRenderer}
/>
);
}
Що означають ці props
width— ширина спискуheight— висота видимої областіrowCount— кількість рядківrowHeight— висота одного рядка-
rowRenderer— функція, яка рендерить рядок
Чому обов’язково треба передавати style
function rowRenderer({ index, key, style }) {
return (
<div key={key} style={style}>
{list[index]}
</div>
);
}
react-virtualized сам позиціонує елементи. Якщо
не передати style у рядок, список працюватиме
неправильно.
Приклад з AutoSizer
import { AutoSizer, List } from "react-virtualized";
import "react-virtualized/styles.css";
const users = Array.from({ length: 5000 }, function(_, index) {
return { id: index, name: "User " + index };
});
function rowRenderer({ index, key, style }) {
const user = users[index];
return (
<div key={key} style={style}>
{user.name}
</div>
);
}
function App() {
return (
<div style={{ width: "100%", height: "400px" }}>
<AutoSizer>
{function({ width, height }) {
return (
<List
width={width}
height={height}
rowCount={users.length}
rowHeight={40}
rowRenderer={rowRenderer}
/>
);
}}
</AutoSizer>
</div>
);
}
AutoSizer автоматично підлаштовує розміри
списку під контейнер.
Приклад з даними об’єктів
import { List } from "react-virtualized";
import "react-virtualized/styles.css";
function UsersList({ users }) {
function rowRenderer({ index, key, style }) {
const user = users[index];
return (
<div key={key} style={style} className="user-row">
<strong>{user.name}</strong>
<p>{user.email}</p>
</div>
);
}
return (
<List
width={700}
height={500}
rowCount={users.length}
rowHeight={70}
rowRenderer={rowRenderer}
/>
);
}
Комбінація useMemo + react-virtualized
import { useMemo, useState } from "react";
import { List } from "react-virtualized";
function UsersPage({ users }) {
const [search, setSearch] = useState("");
const filteredUsers = useMemo(
function() {
return users.filter(function(user) {
return user.name.toLowerCase().includes(search.toLowerCase());
});
},
[users, search]
);
function rowRenderer({ index, key, style }) {
const user = filteredUsers[index];
return (
<div key={key} style={style}>
{user.name}
</div>
);
}
return (
<div>
<input
value={search}
onChange={function(event) {
setSearch(event.target.value);
}}
placeholder="Search user"
/>
<List
width={500}
height={400}
rowCount={filteredUsers.length}
rowHeight={40}
rowRenderer={rowRenderer}
/>
</div>
);
}
Тут useMemo зменшує вартість фільтрації, а
react-virtualized зменшує кількість DOM-вузлів.
Типові помилки з react-virtualized
- Не передають style в rowRenderer
- Да ють неправильну висоту контейнера
- Очікують, що virtualization вирішить усі проблеми без оптимізації логіки
- Не розуміють, що елементи поза viewport фізично не відрендерені
8. Vite: як це організувати
У Vite усе доволі просто: це звичайний клієнтський React-застосунок.
Створення проекту Vite
npm create vite@latest my-app
cd my-app
npm install
npm run dev
Приблизна структура проекту
src/
components/
Card.jsx
Modal.jsx
UserList.jsx
context/
ThemeContext.jsx
AuthContext.jsx
hooks/
useTheme.js
pages/
HomePage.jsx
App.jsx
main.jsx
Приклад ThemeContext для Vite
// src/context/ThemeContext.jsx
import { createContext, useContext, useMemo, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme(function(prevTheme) {
return prevTheme === "light" ? "dark" : "light";
});
};
const value = useMemo(
function() {
return { theme, toggleTheme };
},
[theme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return context;
}
Підключення Provider у Vite
// src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ThemeProvider } from "./context/ThemeContext";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
);
Де використовувати composition у Vite
- components/ — дрібні UI-блоки
- layouts/ — загальні каркаси сторінок
- pages/ — сторінки, які збирають усе разом
9. Next.js: як це організувати
У Next.js треба пам’ятати про різницю між App Router і Pages Router.
Головна відмінність
- Vite — повністю клієнтський React
- Next.js Pages Router — класичний React-підхід у папці pages
- Next.js App Router — за замовчуванням використовує Server Components
Що це означає для Context, useMemo і useCallback у Next.js App Router
Якщо компонент використовує state, event handlers, browser
API,
useContext, useMemo,
useCallback або інші клієнтські React hooks,
він має бути client component.
"use client";
import { useMemo, useState } from "react";
export default function SearchList({ items }) {
const [search, setSearch] = useState("");
const filteredItems = useMemo(
function() {
return items.filter(function(item) {
return item.title.toLowerCase().includes(search.toLowerCase());
});
},
[items, search]
);
return (
<div>
<input
value={search}
onChange={function(event) {
setSearch(event.target.value);
}}
/>
<ul>
{filteredItems.map(function(item) {
return <li key={item.id}>{item.title}</li>;
})}
</ul>
</div>
);
}
Приблизна структура для Next.js App Router
app/
layout.jsx
page.jsx
providers.jsx
components/
Card.jsx
SearchList.jsx
context/
ThemeContext.jsx
hooks/
useTheme.js
Providers у Next.js App Router
// app/providers.jsx
"use client";
import { ThemeProvider } from "../context/ThemeContext";
export default function Providers({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
Підключення providers у layout
// app/layout.jsx
import Providers from "./providers";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
ThemeContext для Next.js App Router
// context/ThemeContext.jsx
"use client";
import { createContext, useContext, useMemo, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme(function(prevTheme) {
return prevTheme === "light" ? "dark" : "light";
});
};
const value = useMemo(
function() {
return { theme, toggleTheme };
},
[theme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return context;
}
Важливо для Next.js App Router
- Server Component не може використовувати клієнтські hooks
- Context Provider для інтерактивного стану зазвичай роблять у client component
- react-virtualized теж використовують у client component
react-virtualized у Next.js App Router
// components/VirtualUsersList.jsx
"use client";
import { List } from "react-virtualized";
import "react-virtualized/styles.css";
export default function VirtualUsersList({ users }) {
function rowRenderer({ index, key, style }) {
const user = users[index];
return (
<div key={key} style={style}>
{user.name}
</div>
);
}
return (
<List
width={500}
height={400}
rowCount={users.length}
rowHeight={40}
rowRenderer={rowRenderer}
/>
);
}
Pages Router: що змінюється
Якщо використовується Pages Router, то підхід ближчий до звичайного React.
pages/
_app.jsx
index.jsx
components/
context/
hooks/
Підключення Provider у Pages Router
// pages/_app.jsx
import { ThemeProvider } from "../context/ThemeContext";
export default function App({ Component, pageProps }) {
return (
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
);
}
Порівняння Vite, Next App Router і Next Pages Router
- Vite: простий клієнтський React, усе працює як звично
- Next Pages Router: теж майже звичний React, глобальні provider-и зручно підключати в _app.jsx
- Next App Router: треба стежити, які компоненти є server, а які client
10. Практичний міні-патерн: Composition + Context + Optimization
import { createContext, memo, useCallback, useContext, useMemo, useState } from "react";
const FiltersContext = createContext(null);
function FiltersProvider({ children }) {
const [search, setSearch] = useState("");
const value = useMemo(
function() {
return { search, setSearch };
},
[search]
);
return <FiltersContext.Provider value={value}>{children}</FiltersContext.Provider>;
}
function useFilters() {
const context = useContext(FiltersContext);
if (!context) {
throw new Error("useFilters must be used inside FiltersProvider");
}
return context;
}
const SearchInput = memo(function SearchInput() {
const { search, setSearch } = useFilters();
const handleChange = useCallback(function(event) {
setSearch(event.target.value);
}, [setSearch]);
return <input value={search} onChange={handleChange} placeholder="Search" />;
});
const UserList = memo(function UserList({ users }) {
const { search } = useFilters();
const filteredUsers = useMemo(
function() {
return users.filter(function(user) {
return user.name.toLowerCase().includes(search.toLowerCase());
});
},
[users, search]
);
return (
<ul>
{filteredUsers.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
});
export default function App({ users }) {
return (
<FiltersProvider>
<SearchInput />
<UserList users={users} />
</FiltersProvider>
);
}
Тут одночасно використано:
- Composition — інтерфейс складено з окремих компонентів
- Context — search доступний в різних компонентах
- useMemo — оптимізація фільтрації
- useCallback — стабільний обробник
- memo — захист від зайвих ререндерів
11. Найтиповіші помилки trainee-рівня
- Пхають усе в Context замість локального state
- Використовують useMemo і useCallback скрізь без аналізу
- Не розділяють великі компоненти через composition
- Не розуміють, що новий об’єкт і нова функція мають нове посилання
- Забувають залежності в hooks
- Використовують virtualization там, де список маленький
- У Next.js App Router забувають про "use client"
12. Коли що використовувати — швидка шпаргалка
- Composition — майже завжди, коли компонент стає занадто великим
- Context — коли одні й ті самі дані потрібні багатьом компонентам на різних рівнях
- useMemo — коли є дороге обчислення або потрібне стабільне значення
- useCallback — коли функція передається вниз і це реально впливає на ререндери
- React.memo — коли компонент часто ререндериться з однаковими props
- react-virtualized — коли список або таблиця дуже великі
13. Порядок мислення при розв’язанні задачі
- Спочатку зроби просту робочу версію
- Потім розбий інтерфейс на компоненти через composition
- Подумай, чи потрібен Context, чи вистачить props
- Перевір, чи є реальна проблема з продуктивністю
- Лише після цього додавай useMemo, useCallback, memo або virtualization
14. Підсумок
Composition — це основа зручної архітектури компонентів у React.
Context — це інструмент для передачі даних без prop drilling, але його треба використовувати обережно.
useMemo і useCallback — це не “обов’язкові хуки”, а точкові інструменти оптимізації.
react-virtualized потрібен тоді, коли головна проблема — велика кількість DOM-елементів.
У Vite все простіше, а в Next.js особливо важливо розуміти різницю між client і server components.
15. Міні-шпаргалка по командах
Vite
npm create vite@latest my-app
cd my-app
npm install
npm run dev
npm install react-virtualized
Next.js
npx create-next-app@latest my-app
cd my-app
npm run dev
npm install react-virtualized
Tests. Unit Testing Jest. Testing React Components. React Testing Library
Тестування у фронтенді — це спосіб перевірити, що код працює саме так, як ти очікуєш. Воно допомагає ловити помилки раніше, без ручного проклікування всього застосунку після кожної зміни.
Для trainee рівня найважливіше зрозуміти базову ідею: ми тестуємо не "внутрішню магію" компонента, а його поведінку. Тобто що бачить користувач, що можна натиснути, який текст з’являється, які колбеки викликаються, як компонент реагує на props, state та асинхронні дані.
Найчастіше у React-проєктах для цього використовують Jest як тестовий раннер і assertion tool, а React Testing Library — як інструмент для рендеру компонентів і взаємодії з DOM так, як це робив би користувач.
Що повинен розуміти front-end розробник про тести
- Навіщо потрібні тести
- Різницю між unit, integration та e2e тестами
- Що таке test runner
- Що таке assertion
- Що таке mocking
- Що таке jsdom
- Що таке render компонента в тесті
- Різницю між fireEvent і userEvent
- Різницю між getBy, queryBy, findBy
- Як тестувати асинхронну поведінку
- Як тестувати props, callbacks, умовний рендер
- Як тестувати React Context
- Коли використовувати data-testid, а коли ні
Основні типи тестів
Unit test — тестує маленьку частину логіки ізольовано. Наприклад, окрему функцію formatDate, validateEmail або утиліту для сортування.
Integration test — перевіряє, як декілька частин працюють разом. Наприклад, форма + валідація + кнопка submit + відображення помилки.
E2E test — перевіряє реальний сценарій користувача у браузері. Наприклад, логін, перехід на dashboard, створення нового елемента.
У цьому гайді основний акцент саме на unit testing і testing React components.
Що таке Jest
Jest — це тестовий інструмент для JavaScript. Він запускає тести, перевіряє очікування через expect, уміє мокати функції та модулі, працює зі snapshot-тестами і підтримує jsdom для DOM-подібного середовища.
Простіше кажучи: Jest відповідає за запуск і перевірку тестів.
Що таке React Testing Library
React Testing Library рендерить компонент у тестовий DOM і дає API для пошуку елементів та взаємодії з ними.
Її головна ідея: тестувати компонент так, як ним користується людина, а не так, як він реалізований всередині.
Тому тут зазвичай шукають елементи по ролі, тексту, label, placeholder, а не по CSS-класах чи внутрішніх змінних.
Що таке jsdom
jsdom — це середовище, яке імітує браузерний DOM у Node.js. Завдяки цьому тести можуть працювати з document, window, button, input та іншими DOM API без реального браузера.
Базова термінологія тестів
- describe — групує тести
- it або test — окремий тест-кейс
- expect — перевірка очікуваного результату
- mock — підробка функції або модуля
- spy — спостереження за викликом функції
- render — рендер React-компонента в тест
- screen — доступ до DOM після render
- cleanup — очищення після тестів
Мінімальна структура тестів
describe("sum function", function () {
it("should add two numbers", function () {
expect(2 + 3).toBe(5);
});
});
Пояснення
Тут describe описує групу тестів. Усередині it описано один конкретний сценарій. expect перевіряє, що фактичний результат дорівнює очікуваному.
Unit Testing Jest — тестування звичайних функцій
Починати навчання тестуванню найкраще зі звичайних JavaScript-функцій. Там немає React, DOM, useState і асинхронного рендера — тільки вхідні дані й результат.
Приклад функції
// utils/math.js
export function sum(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
Тести для функції
// utils/math.test.js
import { divide, sum } from "./math";
describe("math utils", function () {
it("should return sum of two numbers", function () {
expect(sum(2, 3)).toBe(5);
});
it("should divide two numbers", function () {
expect(divide(10, 2)).toBe(5);
});
it("should throw error when dividing by zero", function () {
expect(function () {
divide(10, 0);
}).toThrow("Division by zero");
});
});
Що тут важливо
- Перевіряй і нормальні сценарії, і помилки
- Один тест — один зрозумілий сценарій
- Назва тесту повинна пояснювати поведінку
- Тести мають бути незалежними один від одного
Найпопулярніші matchers у Jest
expect(value).toBe(10);
expect(value).toEqual({ name: "John" });
expect(list).toHaveLength(3);
expect(text).toContain("Hello");
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith("test");
expect(element).toBeInTheDocument();
expect(input).toHaveValue("admin");
expect(button).toBeDisabled();
expect(errorMessage).toHaveTextContent("Required");
Різниця між toBe і toEqual
toBe підходить для примітивів і перевіряє строгу рівність.
toEqual використовується для масивів і об’єктів, коли потрібно порівняти структуру та значення.
Testing React Components — з чого почати
Тест компонента майже завжди має однакову структуру:
- рендеримо компонент
- знаходимо елемент у DOM
- виконуємо дію або перевірку
- переконуємось, що результат правильний
Простий компонент
// components/Greeting.jsx
export default function Greeting() {
return <h1>Hello, React testing</h1>;
}
Тест простого компонента
// components/Greeting.test.jsx
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import Greeting from "./Greeting";
describe("Greeting", function () {
it("should render heading text", function () {
render(<Greeting />);
expect(
screen.getByRole("heading", { name: "Hello, React testing" })
).toBeInTheDocument();
});
});
Що тут відбувається
render вставляє компонент у тестовий DOM. screen дозволяє шукати елементи в DOM. getByRole знаходить елемент по ролі та доступній назві. Це найкращий спосіб пошуку в більшості випадків.
Пріоритет пошуку елементів у React Testing Library
Найкраще шукати елементи так, як їх сприймає користувач і assistive technology.
- getByRole
- getByLabelText
- getByPlaceholderText
- getByText
- getByDisplayValue
- getByAltText
- getByTitle
- getByTestId — тільки якщо нормальний спосіб недоступний
Приклад форми для пошуку
// components/LoginForm.jsx
export default function LoginForm() {
return (
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
<label htmlFor="password">Password</label>
<input id="password" type="password" />
<button type="submit">Log in</button>
</form>
);
}
Тест форми
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import LoginForm from "./LoginForm";
describe("LoginForm", function () {
it("should render email, password and submit button", function () {
render(<LoginForm />);
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Log in" })
).toBeInTheDocument();
});
});
getBy, queryBy, findBy — у чому різниця
- getBy... — елемент повинен бути в DOM прямо зараз. Якщо його нема, тест впаде.
- queryBy... — повертає null, якщо елемента нема. Зручно для перевірки відсутності.
- findBy... — асинхронно чекає, поки елемент з’явиться. Повертає Promise.
Приклади
expect(screen.getByText("Profile")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
const successMessage = await screen.findByText("Saved");
expect(successMessage).toBeInTheDocument();
Тестування props
// components/UserCard.jsx
export default function UserCard(props) {
return (
<article>
<h2>{props.name}</h2>
<p>Age: {props.age}</p>
</article>
);
}
Тестування компонента з props
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import UserCard from "./UserCard";
describe("UserCard", function () {
it("should render data from props", function () {
render(<UserCard name="Viktor" age={25} />);
expect(screen.getByRole("heading", { name: "Viktor" })).toBeInTheDocument();
expect(screen.getByText("Age: 25")).toBeInTheDocument();
});
});
Тестування умовного рендера
// components/StatusMessage.jsx
export default function StatusMessage(props) {
if (props.isLoading) {
return <p>Loading...</p>;
}
if (props.error) {
return <p>Error: {props.error}</p>;
}
return <p>Data loaded</p>;
}
Тести умовного рендера
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import StatusMessage from "./StatusMessage";
describe("StatusMessage", function () {
it("should render loading state", function () {
render(<StatusMessage isLoading={true} error="" />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("should render error state", function () {
render(<StatusMessage isLoading={false} error="Network failed" />);
expect(screen.getByText("Error: Network failed")).toBeInTheDocument();
});
it("should render success state", function () {
render(<StatusMessage isLoading={false} error="" />);
expect(screen.getByText("Data loaded")).toBeInTheDocument();
});
});
Тестування подій у компоненті
Для взаємодії з компонентом можна використовувати fireEvent або userEvent.
fireEvent — більш низькорівневий інструмент.
userEvent — кращий для більшості випадків, бо ближчий до реальної поведінки користувача.
Компонент кнопки
// components/Counter.jsx
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(function (prevCount) {
return prevCount + 1;
});
}
return (
<div>
<p>Count: {count}</p>
<button type="button" onClick={handleIncrement}>
Increment
</button>
</div>
);
}
Тест з userEvent
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import Counter from "./Counter";
describe("Counter", function () {
it("should increment count after click", async function () {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
});
Той самий приклад з fireEvent
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import Counter from "./Counter";
describe("Counter", function () {
it("should increment count after click", function () {
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
});
Що краще використовувати
Для trainee практично завжди краще починати з userEvent. Він краще підходить для кліків, вводу тексту, очищення поля, tab-переходів і загалом ближчий до реальної взаємодії користувача.
Тестування input і форми
// components/SearchInput.jsx
import { useState } from "react";
export default function SearchInput() {
const [value, setValue] = useState("");
return (
<div>
<label htmlFor="search">Search</label>
<input
id="search"
type="text"
value={value}
onChange={function (event) {
setValue(event.target.value);
}}
/>
<p>Current value: {value}</p>
</div>
);
}
Тест input
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import SearchInput from "./SearchInput";
describe("SearchInput", function () {
it("should update input value", async function () {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByLabelText("Search");
await user.type(input, "react");
expect(input).toHaveValue("react");
expect(screen.getByText("Current value: react")).toBeInTheDocument();
});
});
Тестування callback props
// components/DeleteButton.jsx
export default function DeleteButton(props) {
return (
<button type="button" onClick={props.onDelete}>
Delete
</button>
);
}
Тест callback props
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import DeleteButton from "./DeleteButton";
describe("DeleteButton", function () {
it("should call onDelete when clicked", async function () {
const user = userEvent.setup();
const handleDelete = jest.fn();
render(<DeleteButton onDelete={handleDelete} />);
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(handleDelete).toHaveBeenCalledTimes(1);
});
});
Навіщо тут jest.fn()
jest.fn() створює мок-функцію. Вона не виконує реальну логіку, але дозволяє перевірити, чи викликали її, скільки разів і з якими аргументами.
Тестування виклику з аргументами
// components/Item.jsx
export default function Item(props) {
return (
<button
type="button"
onClick={function () {
props.onSelect(props.id);
}}
>
Select
</button>
);
}
Тест
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Item from "./Item";
describe("Item", function () {
it("should call onSelect with item id", async function () {
const user = userEvent.setup();
const handleSelect = jest.fn();
render(<Item id="42" onSelect={handleSelect} />);
await user.click(screen.getByRole("button", { name: "Select" }));
expect(handleSelect).toHaveBeenCalledWith("42");
});
});
Тестування списків
// components/TodoList.jsx
export default function TodoList(props) {
return (
<ul>
{props.items.map(function (item) {
return <li key={item.id}>{item.title}</li>;
})}
</ul>
);
}
Тест списку
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import TodoList from "./TodoList";
describe("TodoList", function () {
it("should render all list items", function () {
render(
<TodoList
items={[
{ id: 1, title: "Learn Jest" },
{ id: 2, title: "Learn RTL" },
{ id: 3, title: "Write tests" }
]}
/>
);
expect(screen.getByText("Learn Jest")).toBeInTheDocument();
expect(screen.getByText("Learn RTL")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
});
Тестування асинхронних компонентів
Асинхронність — одна з найскладніших тем для початківців. Найчастіше вона виникає під час fetch, axios, setTimeout, loading state, delayed render або оновлення state після Promise.
Асинхронний приклад
// components/AsyncMessage.jsx
import { useEffect, useState } from "react";
export default function AsyncMessage() {
const [message, setMessage] = useState("Loading...");
useEffect(function () {
const timerId = setTimeout(function () {
setMessage("Data loaded");
}, 300);
return function () {
clearTimeout(timerId);
};
}, []);
return <p>{message}</p>;
}
Тест через findByText
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import AsyncMessage from "./AsyncMessage";
describe("AsyncMessage", function () {
it("should render final message after async update", async function () {
render(<AsyncMessage />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Data loaded")).toBeInTheDocument();
});
});
Коли використовувати waitFor
waitFor корисний тоді, коли потрібно дочекатися певної умови, а не просто знайти елемент.
Приклад waitFor
import { render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import AsyncMessage from "./AsyncMessage";
describe("AsyncMessage", function () {
it("should wait until loading text disappears", async function () {
render(<AsyncMessage />);
await waitFor(function () {
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
expect(screen.getByText("Data loaded")).toBeInTheDocument();
});
});
Просте правило
- Елемент з’явиться пізніше — використовуй findBy
- Потрібно дочекатися умови — використовуй waitFor
- Елемента не повинно бути — використовуй queryBy
Тестування fetch або API-запитів
У unit та component тестах зазвичай не ходять у справжній API. Замість цього API мокають.
Компонент з API-сервісом
// services/userService.js
export async function getUsers() {
const response = await fetch("/api/users");
return response.json();
}
// components/Users.jsx
import { useEffect, useState } from "react";
import { getUsers } from "../services/userService";
export default function Users() {
const [users, setUsers] = useState([]);
useEffect(function () {
async function loadUsers() {
const data = await getUsers();
setUsers(data);
}
loadUsers();
}, []);
return (
<ul>
{users.map(function (user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Мок модуля і тест
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import Users from "./Users";
import { getUsers } from "../services/userService";
jest.mock("../services/userService", function () {
return {
getUsers: jest.fn()
};
});
describe("Users", function () {
it("should render users from mocked service", async function () {
getUsers.mockResolvedValue([
{ id: 1, name: "John" },
{ id: 2, name: "Anna" }
]);
render(<Users />);
expect(await screen.findByText("John")).toBeInTheDocument();
expect(await screen.findByText("Anna")).toBeInTheDocument();
});
});
Що тут відбувається
Ми підміняємо справжню функцію getUsers мок-версією. У тесті кажемо, що вона має повернути масив користувачів. Так ми тестуємо компонент, а не реальний бекенд.
Тестування помилки API
// components/UsersWithError.jsx
import { useEffect, useState } from "react";
import { getUsers } from "../services/userService";
export default function UsersWithError() {
const [users, setUsers] = useState([]);
const [error, setError] = useState("");
useEffect(function () {
async function loadUsers() {
try {
const data = await getUsers();
setUsers(data);
} catch (err) {
setError("Failed to load users");
}
}
loadUsers();
}, []);
if (error) {
return <p>{error}</p>;
}
return (
<ul>
{users.map(function (user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Тест помилки
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import UsersWithError from "./UsersWithError";
import { getUsers } from "../services/userService";
jest.mock("../services/userService", function () {
return {
getUsers: jest.fn()
};
});
describe("UsersWithError", function () {
it("should render error message when service fails", async function () {
getUsers.mockRejectedValue(new Error("Network error"));
render(<UsersWithError />);
expect(
await screen.findByText("Failed to load users")
).toBeInTheDocument();
});
});
Тестування React Context
Якщо компонент використовує useContext, у тесті треба обгорнути його в відповідний Provider.
Приклад контексту
// context/ThemeContext.jsx
import { createContext } from "react";
export const ThemeContext = createContext("light");
// components/ThemeLabel.jsx
import { useContext } from "react";
import { ThemeContext } from "../context/ThemeContext";
export default function ThemeLabel() {
const theme = useContext(ThemeContext);
return <p>Theme: {theme}</p>;
}
Тест з Provider
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import ThemeLabel from "./ThemeLabel";
import { ThemeContext } from "../context/ThemeContext";
describe("ThemeLabel", function () {
it("should render value from ThemeContext", function () {
render(
<ThemeContext.Provider value="dark">
<ThemeLabel />
</ThemeContext.Provider>
);
expect(screen.getByText("Theme: dark")).toBeInTheDocument();
});
});
Кастомний render для Provider-ів
Якщо у тебе багато компонентів з Redux, Router, Context або ThemeProvider, зручно зробити renderWithProviders.
Приклад renderWithProviders
// tests/test-utils.jsx
import { render } from "@testing-library/react";
import { ThemeContext } from "../context/ThemeContext";
export function renderWithProviders(ui) {
return render(
<ThemeContext.Provider value="dark">{ui}</ThemeContext.Provider>
);
}
Використання
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import ThemeLabel from "../components/ThemeLabel";
import { renderWithProviders } from "./test-utils";
describe("ThemeLabel", function () {
it("should work with custom render", function () {
renderWithProviders(<ThemeLabel />);
expect(screen.getByText("Theme: dark")).toBeInTheDocument();
});
});
Тестування router-залежних компонентів
Якщо компонент використовує Link, useNavigate, useLocation або next/router, його теж часто треба обгорнути у потрібний router context або мокнути router.
Приклад для React Router
import { MemoryRouter } from "react-router-dom";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import HeaderLink from "./HeaderLink";
describe("HeaderLink", function () {
it("should render link inside router", function () {
render(
<MemoryRouter>
<HeaderLink />
</MemoryRouter>
);
expect(screen.getByRole("link", { name: "Home" })).toBeInTheDocument();
});
});
Тестування класів і атрибутів
// components/Badge.jsx
export default function Badge(props) {
return (
<span className={props.active ? "badge active" : "badge"}>
Status
</span>
);
}
Тест
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import Badge from "./Badge";
describe("Badge", function () {
it("should have active class when active=true", function () {
render(<Badge active={true} />);
expect(screen.getByText("Status")).toHaveClass("active");
});
});
Snapshot testing
Snapshot-тест зберігає знімок виводу компонента і порівнює його в наступних запусках. Це може бути корисно для простих презентаційних компонентів, але не варто покладатися тільки на snapshot.
Приклад snapshot-тесту
import { render } from "@testing-library/react";
import Badge from "./Badge";
describe("Badge", function () {
it("should match snapshot", function () {
const { container } = render(<Badge active={true} />);
expect(container).toMatchSnapshot();
});
});
Коли snapshot доречний
- Для дуже простих UI-компонентів
- Коли хочеш швидко відслідкувати зміни розмітки
- Як додатковий тест, а не єдиний
beforeEach, afterEach, beforeAll, afterAll
describe("example", function () {
beforeEach(function () {
jest.clearAllMocks();
});
it("first test", function () {
expect(true).toBe(true);
});
it("second test", function () {
expect(true).toBe(true);
});
});
Для чого це потрібно
Найчастіше beforeEach використовують для очищення моків, скидання стану або підготовки тестового оточення перед кожним тестом.
Моки в Jest
Основні способи
- jest.fn() — створити мок-функцію
- jest.mock() — замокати цілий модуль
- mockResolvedValue() — Promise resolve
- mockRejectedValue() — Promise reject
- mockImplementation() — власна реалізація
- jest.spyOn() — слідкувати за методом об’єкта
Приклад spyOn
const math = {
sum: function (a, b) {
return a + b;
}
};
describe("spy example", function () {
it("should track function call", function () {
const spy = jest.spyOn(math, "sum");
math.sum(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3);
spy.mockRestore();
});
});
Fake timers
Якщо в коді є setTimeout або setInterval, зручно використовувати fake timers.
Приклад
describe("timer example", function () {
beforeEach(function () {
jest.useFakeTimers();
});
afterEach(function () {
jest.useRealTimers();
});
it("should run delayed callback", function () {
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.runAllTimers();
expect(callback).toHaveBeenCalledTimes(1);
});
});
Коли потрібен data-testid
data-testid — це запасний варіант. Його краще використовувати тільки тоді, коли елемент складно або неможливо знайти нормальним способом.
Приклад
// component
<div data-testid="loader">Loading spinner</div>
// test
expect(screen.getByTestId("loader")).toBeInTheDocument();
Коли краще не використовувати data-testid
- Для кнопок
- Для input з label
- Для заголовків
- Для посилань
- Для тексту, який легко знайти через role або text
Debug у React Testing Library
screen.debug();
Це дуже корисно, коли не розумієш, що реально відрендерилось у тестовому DOM.
Як організовувати тести
Популярні підходи
- Поруч із файлом компонента: Button.jsx і Button.test.jsx
- Окрема папка tests
- Окрема папка __tests__
Приклад структури
src/
components/
Button.jsx
Button.test.jsx
services/
userService.js
userService.test.js
tests/
test-utils.jsx
setupTests.js
Як писати хороші тести
- Тестуй поведінку, а не внутрішню реалізацію
- Давай тестам зрозумілі назви
- Один тест — один сценарій
- Не змішуй багато очікувань без потреби
- Уникай залежності між тестами
- Мокай тільки те, що справді потрібно мокати
- Не тестуй React або бібліотеки замість свого коду
Arrange, Act, Assert
Дуже корисна ментальна модель для будь-якого тесту.
- Arrange — підготуй дані та рендер
- Act — виконай дію
- Assert — перевір результат
Приклад
it("should increment count", async function () {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
Встановлення Jest і React Testing Library у React + Vite
Тут є важливий нюанс: у Vite дуже часто використовують Vitest, бо він краще інтегрується з Vite-екосистемою. Але якщо тобі потрібно саме Jest, то він теж використовується — просто конфігурація буде більш ручною.
Створення Vite-проєкту
npm create vite@latest my-app
cd my-app
npm install
Встановлення пакетів для Jest у Vite
npm install --save-dev jest jest-environment-jsdom babel-jest @babel/preset-env @babel/preset-react @testing-library/react @testing-library/jest-dom @testing-library/user-event identity-obj-proxy
Приклад package.json scripts для Vite + Jest
{
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
babel.config.cjs
module.exports = {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current"
}
}
],
[
"@babel/preset-react",
{
runtime: "automatic"
}
]
]
};
jest.config.cjs
module.exports = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/src/setupTests.js"],
moduleNameMapper: {
"\\\\.(css|less|scss|sass)$": "identity-obj-proxy"
},
transform: {
"^.+\\\\.(js|jsx)$": "babel-jest"
},
testPathIgnorePatterns: ["/node_modules/", "/dist/"]
};
src/setupTests.js
import "@testing-library/jest-dom";
Що важливо у Vite
- Vite працює в ESM-стилі, тому Jest-конфігурація тут менш "нативна"
- Для JSX потрібен babel-jest
- Для DOM середовища потрібен jsdom
- CSS-імпорти в тестах часто маплять через identity-obj-proxy
Встановлення Jest і React Testing Library у Next.js
У Next.js налаштування зручніше робити через next/jest, тому що Next сам допомагає з конфігурацією трансформацій, стилів і середовища.
Створення Next.js проєкту
npx create-next-app@latest my-next-app
cd my-next-app
npm install
Встановлення тестових пакетів
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
jest.config.js для Next.js
const nextJest = require("next/jest");
const createJestConfig = nextJest({
dir: "./"
});
const customJestConfig = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1"
}
};
module.exports = createJestConfig(customJestConfig);
jest.setup.js
import "@testing-library/jest-dom";
scripts у package.json для Next.js
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Що важливо у Next.js
- Зручно використовувати next/jest
- Псевдоніми через @/ можна додати в moduleNameMapper
- Client Components тестуються звично
- Async Server Components у Jest — складна тема, і їх зазвичай радять перевіряти e2e або тестувати пов’язану логіку окремо
Vite vs Next.js — різниця в тестуванні
- У Vite з Jest налаштування більш ручне: Babel, transform, css mocks
- У Next.js зручніше через next/jest, бо фреймворк допомагає з конфігом
- У Vite частіше обирають Vitest, але Jest теж можливий
- У Next.js треба окремо думати про різницю між Client Components і Server Components
- Для звичайних React-компонентів із props, state, forms і events підхід майже однаковий
Тестування Next.js Client Component
// app/components/CounterClient.jsx
"use client";
import { useState } from "react";
export default function CounterClient() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button
type="button"
onClick={function () {
setCount(function (prevCount) {
return prevCount + 1;
});
}}
>
Increment
</button>
</div>
);
}
Тест
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import CounterClient from "./CounterClient";
describe("CounterClient", function () {
it("should increment counter", async function () {
const user = userEvent.setup();
render(<CounterClient />);
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
});
Мокання next/router або next/navigation
У Next.js компоненти часто використовують router hooks. У тестах їх зазвичай мокають.
Приклад компонента з useRouter
// app/components/GoHomeButton.jsx
"use client";
import { useRouter } from "next/navigation";
export default function GoHomeButton() {
const router = useRouter();
return (
<button
type="button"
onClick={function () {
router.push("/");
}}
>
Go home
</button>
);
}
Тест з моканням router
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import GoHomeButton from "./GoHomeButton";
const pushMock = jest.fn();
jest.mock("next/navigation", function () {
return {
useRouter: function () {
return {
push: pushMock
};
}
};
});
describe("GoHomeButton", function () {
it("should navigate to home page", async function () {
const user = userEvent.setup();
render(<GoHomeButton />);
await user.click(screen.getByRole("button", { name: "Go home" }));
expect(pushMock).toHaveBeenCalledWith("/");
});
});
Тестування custom hooks
Якщо hook містить бізнес-логіку, його теж варто тестувати. Для цього часто використовують renderHook.
Приклад custom hook
// hooks/useToggle.js
import { useState } from "react";
export function useToggle(initialValue) {
const [value, setValue] = useState(initialValue);
function toggle() {
setValue(function (prevValue) {
return !prevValue;
});
}
return {
value,
toggle
};
}
Приклад тесту ідеї
// Підхід залежить від версії бібліотек.
// Часто hook тестують або через окремий компонент,
// або через спеціальні helper-и для hooks.
Для trainee на початку достатньо тестувати hook через компонент, який його використовує. Так простіше зрозуміти поведінку.
Покриття коду
npm run test:coverage
Coverage показує, яка частина коду була виконана під час тестів. Але велике покриття саме по собі не гарантує якість. Можна мати 100% coverage і слабкі тести.
Що важливіше за coverage
- Чи перевірені основні сценарії
- Чи перевірені edge cases
- Чи перевірені помилки
- Чи справді тест ловить регресії
Корисні команди
npm test
npm run test:watch
npm run test:coverage
npx jest Button.test.jsx
npx jest --watch
npx jest --coverage
Типові помилки початківців
- Тестують внутрішній state напряму замість поведінки
- Шукають елементи по CSS-класах замість role або label
- Використовують getBy там, де треба findBy
- Забувають await для userEvent або async query
- Не мокають API і отримують нестабільні тести
- Пишуть занадто великі тести на все одразу
- Зловживають snapshot-тестами
- Не очищають моки між тестами
- Перевіряють реалізацію, а не результат для користувача
Практичний шаблон тесту компонента
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import ComponentName from "./ComponentName";
describe("ComponentName", function () {
it("should do something after user interaction", async function () {
const user = userEvent.setup();
render(<ComponentName />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(screen.getByText("Success")).toBeInTheDocument();
});
});
Що вчити у першу чергу
- describe, it, expect
- toBe, toEqual, toHaveBeenCalled
- render, screen
- getByRole, getByLabelText, getByText
- userEvent.click і userEvent.type
- queryBy і findBy
- jest.fn і jest.mock
- асинхронні тести
- тестування props, forms, callbacks
- тестування компонентів із Provider
Повна картина для trainee front-end розробника
Jest запускає тести, перевіряє assert-вирази та допомагає з моками.
React Testing Library рендерить компоненти та дає інструменти для перевірки DOM через поведінку користувача.
У Vite Jest потребує більш ручного налаштування.
У Next.js Jest зручніше підключати через next/jest.
Для більшості React-компонентів головна ідея однакова: рендер, дія, перевірка результату.
Якщо ти навчишся добре тестувати прості функції, форми, кнопки, списки, умовний рендер, callbacks і асинхронні стани — у тебе вже буде дуже хороша база для реальних проєктів.
Міні-шпаргалка
- Простий елемент є відразу — getBy
- Елемент не повинен існувати — queryBy
- Елемент з’явиться пізніше — findBy
- Користувацька взаємодія — userEvent
- Перевірка викликів — jest.fn()
- Підміна модуля — jest.mock()
- Контекст або router — обгорни в Provider
- Проблема з DOM у тесті — screen.debug()
- Тестуй поведінку, а не внутрішню реалізацію
Material UI — що повинен знати Front-end розробник
Material UI (MUI) — це популярна бібліотека React-компонентів, яка реалізує готові UI-елементи: кнопки, поля вводу, модальні вікна, таблиці, карточки, меню, сітки та багато іншого.
Вона допомагає швидше збирати інтерфейси, дотримуватись єдиного стилю в проєкті та централізовано налаштовувати кольори, типографіку, відступи, стани та поведінку компонентів.
Для trainee-рівня важливо не просто вміти вставити готову кнопку, а й розуміти, як працюють theme, sx, styled, глобальні overrides та як правильно інтегрувати MUI у Vite і Next.js.
Що таке MUI простими словами
- MUI = готові React-компоненти для інтерфейсу
- Компоненти вже мають базову стилізацію та логіку
- Компоненти можна кастомізувати локально або глобально
- Є система темізації через ThemeProvider
- Є адаптивність через breakpoints
- Є власний підхід до стилів через sx та styled
- Добре підходить для dashboard, admin panel, form-heavy UI
Що потрібно знати перед MUI
- React components
- props
- children
- state
- events
- условний рендеринг
- CSS basics
- flexbox
- responsive design
Основні пакети MUI
- @mui/material — основна бібліотека компонентів
- @emotion/react — стилізація
- @emotion/styled — styled API
- @mui/icons-material — іконки
- @mui/material-nextjs — інтеграція з Next.js
Встановлення MUI у Vite
Для звичайного React-проєкту на Vite зазвичай достатньо встановити базові пакети MUI та Emotion.
Створення проєкту Vite
npm create vite@latest my-mui-app
cd my-mui-app
npm install
npm run dev
Встановлення Material UI
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
Найпростіший приклад використання
import Button from "@mui/material/Button";
export default function App() {
return (
<div>
<h1>MUI basic example</h1>
<Button variant="contained">Click me</Button>
</div>
);
}
Що відбувається в цьому прикладі
- Ми імпортуємо готовий компонент Button
- variant="contained" задає заповнений стиль кнопки
- Компонент уже має стилі, hover, focus і базову доступність
Встановлення MUI у Next.js
У Next.js інтеграція трохи важливіша, ніж у Vite, тому що потрібно коректно налаштувати рендер стилів для SSR / App Router / Pages Router.
Створення Next.js проєкту
npx create-next-app@latest my-next-mui-app
cd my-next-mui-app
npm run dev
Встановлення MUI для Next.js
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
npm install @mui/material-nextjs @emotion/cache @emotion/server
Головна різниця між Vite та Next.js
- У Vite зазвичай достатньо просто встановити пакети та імпортувати компоненти
- У Next.js потрібно враховувати серверний рендеринг стилів
- Для Next.js MUI має окремі офіційні інтеграційні рішення
- В App Router і Pages Router налаштування відрізняються
MUI у Next.js App Router
Це сучасний варіант для нових проєктів Next.js. Тут найчастіше використовують App Router і спеціальний cache provider для MUI.
Приклад структури проєкту
src/
app/
layout.jsx
page.jsx
theme/
theme.js
components/
providers/
ThemeRegistry.jsx
Файл theme/theme.js
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
palette: {
primary: {
main: "#1976d2"
},
secondary: {
main: "#9c27b0"
}
},
typography: {
fontFamily: "Arial, sans-serif"
}
});
export default theme;
Файл components/providers/ThemeRegistry.jsx
"use client";
import * as React from "react";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider, CssBaseline } from "@mui/material";
import theme from "../../theme/theme";
export default function ThemeRegistry(props) {
const { children } = props;
return (
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
);
}
Файл app/layout.jsx
import ThemeRegistry from "../components/providers/ThemeRegistry";
export const metadata = {
title: "MUI App Router Guide",
description: "Learning MUI with Next.js"
};
export default function RootLayout(props) {
const { children } = props;
return (
<html lang="en">
<body>
<ThemeRegistry>{children}</ThemeRegistry>
</body>
</html>
);
}
Файл app/page.jsx
import { Box, Button, Container, Typography } from "@mui/material";
export default function HomePage() {
return (
<Container maxWidth="md">
<Box sx={{ py: 4 }}>
<Typography variant="h3" gutterBottom>
MUI + Next.js App Router
</Typography>
<Typography sx={{ mb: 2 }}>
This page uses Material UI theme and App Router integration.
</Typography>
<Button variant="contained">Start learning</Button>
</Box>
</Container>
);
}
MUI у Next.js Pages Router
Якщо проєкт старіший або використовує pages/, тоді налаштування інше: потрібні _app.js, _document.js та робота з Emotion cache.
Приклад структури Pages Router
src/
pages/
_app.js
_document.js
index.js
src/
createEmotionCache.js
theme.js
Файл src/createEmotionCache.js
import createCache from "@emotion/cache";
export default function createEmotionCache() {
return createCache({ key: "css" });
}
Файл src/theme.js
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
palette: {
primary: {
main: "#0f766e"
}
}
});
export default theme;
Файл pages/_app.js
import * as React from "react";
import { CacheProvider } from "@emotion/react";
import { ThemeProvider, CssBaseline } from "@mui/material";
import createEmotionCache from "../src/createEmotionCache";
import theme from "../src/theme";
const clientSideEmotionCache = createEmotionCache();
export default function MyApp(props) {
const {
Component,
emotionCache = clientSideEmotionCache,
pageProps
} = props;
return (
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</CacheProvider>
);
}
Файл pages/index.js
import { Box, Button, Typography } from "@mui/material";
export default function HomePage() {
return (
<Box sx={{ p: 4 }}>
<Typography variant="h4" gutterBottom>
MUI + Next.js Pages Router
</Typography>
<Button variant="outlined">Read more</Button>
</Box>
);
}
Найуживаніші компоненти MUI
- Button
- TextField
- Checkbox
- Radio
- Select
- Card
- Dialog
- Modal
- AppBar
- Drawer
- Snackbar
- Grid
- Box
- Stack
- Container
- Typography
Базовий приклад форми
import { Box, Button, TextField, Typography } from "@mui/material";
export default function LoginForm() {
return (
<Box
component="form"
sx={{
maxWidth: 400,
display: "flex",
flexDirection: "column",
gap: 2
}}
>
<Typography variant="h5">Login</Typography>
<TextField label="Email" type="email" fullWidth />
<TextField label="Password" type="password" fullWidth />
<Button variant="contained" type="submit">
Sign in
</Button>
</Box>
);
}
Що тут важливо
- Box часто використовують як універсальний контейнер
- component="form" перетворює Box у form
- sx дозволяє швидко задавати стилі
- fullWidth розтягує TextField на всю ширину
Box, Container, Stack, Grid — що обирати
Box
Універсальна обгортка. Її часто використовують замість div, коли потрібні стилі через sx.
import { Box } from "@mui/material";
export default function Example() {
return (
<Box sx={{ p: 2, border: "1px solid #ccc" }}>
Content
</Box>
);
}
Container
Обмежує максимальну ширину контенту і центрує його.
import { Container } from "@mui/material";
export default function Example() {
return (
<Container maxWidth="lg">
Page content
</Container>
);
}
Stack
Зручно для вертикального або горизонтального розміщення елементів з gap.
import { Button, Stack } from "@mui/material";
export default function Example() {
return (
<Stack direction="row" spacing={2}>
<Button variant="contained">Save</Button>
<Button variant="outlined">Cancel</Button>
</Stack>
);
}
Grid
Використовують для колонок і адаптивних layout-рішень.
import { Grid, Paper } from "@mui/material";
export default function Example() {
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>Left block</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>Right block</Paper>
</Grid>
</Grid>
);
}
Typography у MUI
Typography допомагає стандартизувати заголовки, текст, підписи, captions та інші текстові стилі.
Приклад
import { Typography } from "@mui/material";
export default function Example() {
return (
<>
<Typography variant="h1">Heading 1</Typography>
<Typography variant="h4">Heading 4</Typography>
<Typography variant="body1">
This is the main body text.
</Typography>
<Typography variant="body2">
This is the secondary text.
</Typography>
</>
);
}
Найуживаніші variants
- h1-h6 — заголовки
- subtitle1, subtitle2 — підзаголовки
- body1, body2 — основний текст
- caption — дрібний текст
- button — текст кнопок
Кастомізація MUI — повна картина
Кастомізацію потрібно розуміти на трьох рівнях: локально, повторно використовувано та глобально.
1. Локальна кастомізація через sx
Це найзручніший спосіб для одного конкретного компонента.
import { Button } from "@mui/material";
export default function Example() {
return (
<Button
variant="contained"
sx={{
backgroundColor: "#111827",
px: 3,
py: 1.5,
borderRadius: 2,
textTransform: "none",
"&:hover": {
backgroundColor: "#1f2937"
}
}}
>
Custom button
</Button>
);
}
Що можна писати в sx
- звичайні CSS-властивості
- theme-aware значення
- breakpoints
- псевдокласи типу &:hover
- вкладені селектори
2. Повторна кастомізація через styled
Підходить, коли треба створити власний компонент на базі MUI-компонента.
import Button from "@mui/material/Button";
import { styled } from "@mui/material/styles";
const PrimaryButton = styled(Button)({
borderRadius: 12,
padding: "10px 20px",
textTransform: "none",
fontWeight: 700
});
export default function Example() {
return <PrimaryButton variant="contained">Styled button</PrimaryButton>;
}
styled з доступом до theme
import Card from "@mui/material/Card";
import { styled } from "@mui/material/styles";
const CustomCard = styled(Card)(({ theme }) => ({
padding: theme.spacing(3),
borderRadius: theme.shape.borderRadius * 2,
border: "1px solid " + theme.palette.divider
}));
export default function Example() {
return <CustomCard>Card content</CustomCard>;
}
3. Глобальна кастомізація через theme
Це правильний шлях, якщо хочеш, щоб у всьому проєкті кнопки, поля або типографіка виглядали однаково.
Створення теми
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
palette: {
primary: {
main: "#2563eb"
},
secondary: {
main: "#db2777"
},
background: {
default: "#f8fafc"
}
},
typography: {
fontFamily: "Inter, Arial, sans-serif",
h1: {
fontSize: "3rem",
fontWeight: 700
},
button: {
textTransform: "none",
fontWeight: 600
}
},
shape: {
borderRadius: 12
}
});
export default theme;
Підключення теми
import { ThemeProvider, CssBaseline } from "@mui/material";
import theme from "./theme";
export default function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<div>Application</div>
</ThemeProvider>
);
}
Навіщо CssBaseline
- дає базовий reset стилів
- робить поведінку браузерів більш передбачуваною
- добре працює разом із глобальною темою
components у theme — глобальні overrides
Через theme.components можна міняти defaultProps, styleOverrides і variants для конкретних MUI-компонентів.
Приклад глобального налаштування Button
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
components: {
MuiButton: {
defaultProps: {
disableElevation: true
},
styleOverrides: {
root: {
borderRadius: 10,
textTransform: "none",
padding: "10px 18px"
},
containedPrimary: {
backgroundColor: "#111827"
}
}
}
}
});
export default theme;
Приклад глобального налаштування TextField
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
components: {
MuiTextField: {
defaultProps: {
variant: "outlined",
fullWidth: true
}
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: 12
}
}
}
}
});
export default theme;
Palette — кольори теми
У MUI кольори бажано задавати через theme.palette, а не розкидати hex-значення по всьому проєкту.
Приклад кастомної палітри
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
palette: {
primary: {
main: "#1d4ed8",
light: "#60a5fa",
dark: "#1e3a8a",
contrastText: "#ffffff"
},
success: {
main: "#16a34a"
},
warning: {
main: "#f59e0b"
},
error: {
main: "#dc2626"
}
}
});
export default theme;
Використання кольорів у компонентах
import { Alert, Button } from "@mui/material";
export default function Example() {
return (
<>
<Button color="primary" variant="contained">
Primary action
</Button>
<Alert severity="success">Profile saved successfully</Alert>
</>
);
}
Typography у theme
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
typography: {
fontFamily: "Roboto, Arial, sans-serif",
h2: {
fontSize: "2rem",
fontWeight: 700
},
body1: {
fontSize: "1rem",
lineHeight: 1.7
}
}
});
export default theme;
Spacing, shape, shadows
Тема MUI дозволяє централізовано керувати відступами, округленнями і візуальним стилем.
Приклад
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
shape: {
borderRadius: 14
},
spacing: 8
});
export default theme;
Використання spacing у sx
import { Box } from "@mui/material";
export default function Example() {
return (
<Box
sx={{
p: 2,
mt: 3,
mx: "auto",
width: 300
}}
>
Box with spacing
</Box>
);
}
Breakpoints і адаптивність
MUI має готову breakpoint-систему для responsive layout.
Адаптивний sx
import { Box } from "@mui/material";
export default function Example() {
return (
<Box
sx={{
p: { xs: 2, md: 4 },
fontSize: { xs: "14px", sm: "16px", md: "18px" },
display: { xs: "block", md: "flex" },
gap: 2
}}
>
Responsive content
</Box>
);
}
Що означають breakpoint-и
- xs — мобільні
- sm — більші телефони / маленькі планшети
- md — планшети / невеликі ноутбуки
- lg — десктоп
- xl — великі екрани
useMediaQuery у MUI
Якщо адаптивність залежить не тільки від стилів, а й від логіки рендера, використовують useMediaQuery.
import { Typography, useMediaQuery } from "@mui/material";
import { useTheme } from "@mui/material/styles";
export default function Example() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
return (
<Typography variant={isMobile ? "h5" : "h3"}>
Responsive title
</Typography>
);
}
Кастомні компоненти на базі MUI
Правильний підхід у реальному проєкті — створювати свої обгортки над MUI-компонентами.
Приклад AppButton.jsx
import Button from "@mui/material/Button";
export default function AppButton(props) {
const { children, sx, ...rest } = props;
return (
<Button
variant="contained"
sx={{
minWidth: 140,
textTransform: "none",
borderRadius: 3,
...sx
}}
{...rest}
>
{children}
</Button>
);
}
Навіщо так робити
- єдина поведінка всіх кнопок у проєкті
- легше оновлювати дизайн
- менше дублювання sx у різних місцях
Робота з іконками
import DeleteIcon from "@mui/icons-material/Delete";
import { IconButton } from "@mui/material";
export default function Example() {
return (
<IconButton aria-label="delete">
<DeleteIcon />
</IconButton>
);
}
MUI + форми
Material UI часто використовують разом із React Hook Form, Formik або просто локальним state.
Приклад контрольованого TextField
import { useState } from "react";
import { TextField } from "@mui/material";
export default function Example() {
const [email, setEmail] = useState("");
return (
<TextField
label="Email"
value={email}
onChange={(event) => setEmail(event.target.value)}
fullWidth
/>
);
}
Dialog, Modal, Snackbar
Це дуже часті компоненти в реальних React-проєктах.
Приклад Dialog
import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle
} from "@mui/material";
export default function Example() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Delete item</DialogTitle>
<DialogContent>Are you sure you want to continue?</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button color="error" variant="contained">Delete</Button>
</DialogActions>
</Dialog>
</>
);
}
Приклад Snackbar
import { useState } from "react";
import { Alert, Button, Snackbar } from "@mui/material";
export default function Example() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Show message
</Button>
<Snackbar
open={open}
autoHideDuration={3000}
onClose={() => setOpen(false)}
>
<Alert severity="success" onClose={() => setOpen(false)}>
Changes saved successfully
</Alert>
</Snackbar>
</>
);
}
MUI + navigation
За замовчуванням MUI-компоненти для навігації рендерять звичайний anchor, але їх можна інтегрувати з router-рішенням застосунку.
Приклад для Next.js Link
import Link from "next/link";
import Button from "@mui/material/Button";
export default function Example() {
return (
<Button component={Link} href="/dashboard" variant="contained">
Dashboard
</Button>
);
}
Приклад для React Router
import { Link } from "react-router-dom";
import Button from "@mui/material/Button";
export default function Example() {
return (
<Button component={Link} to="/profile" variant="outlined">
Open profile
</Button>
);
}
Організація файлів у реальному проєкті
src/
components/
ui/
AppButton.jsx
AppInput.jsx
AppCard.jsx
theme/
theme.js
palette.js
typography.js
components.js
pages/
features/
layouts/
Чому це хороша структура
- тема зберігається окремо
- повторно використовувані UI-компоненти винесені в ui/
- фіча-код не змішується з глобальною стилізацією
Приклад розділення теми на частини
theme/palette.js
const palette = {
primary: {
main: "#2563eb"
},
secondary: {
main: "#9333ea"
},
background: {
default: "#f8fafc"
}
};
export default palette;
theme/typography.js
const typography = {
fontFamily: "Inter, Arial, sans-serif",
h1: {
fontSize: "2.5rem",
fontWeight: 700
},
button: {
textTransform: "none"
}
};
export default typography;
theme/components.js
const components = {
MuiButton: {
defaultProps: {
disableElevation: true
},
styleOverrides: {
root: {
borderRadius: 10
}
}
}
};
export default components;
theme/theme.js
import { createTheme } from "@mui/material/styles";
import palette from "./palette";
import typography from "./typography";
import components from "./components";
const theme = createTheme({
palette,
typography,
components
});
export default theme;
Типові помилки при роботі з MUI
- Зловживають inline sx у кожному компоненті
- Не виносять повторювані стилі в theme або власні UI-компоненти
- Змішують логіку бізнес-рівня і стилізацію в одному великому файлі
- Не використовують ThemeProvider
- Кидають hex-кольори по всьому проєкту замість palette
- Не враховують SSR-особливості в Next.js
- Використовують MUI-компоненти без розуміння props API
- Не читають назви слотів і класів для styleOverrides
Коли використовувати sx, styled, theme
- sx — коли треба швидко стилізувати один конкретний екземпляр
- styled — коли будуєш свій перевикористовуваний компонент
- theme — коли хочеш глобальну консистентність у всьому застосунку
Швидка пам’ятка для trainee
- Починай із готових компонентів: Button, TextField, Box, Stack
- Для швидких стилів використовуй sx
- Для системного дизайну використовуй createTheme
- Для повторних UI-елементів створи власні обгортки
- У Next.js завжди правильно налаштовуй інтеграцію стилів
- Не дублюй кольори, розміри та radius у різних файлах
- Тримай тему централізованою
Мінімальний повний приклад для Vite
import React from "react";
import ReactDOM from "react-dom/client";
import { Button, CssBaseline, ThemeProvider, createTheme } from "@mui/material";
const theme = createTheme({
palette: {
primary: {
main: "#2563eb"
}
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: "none",
borderRadius: 10
}
}
}
}
});
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<div style={{ padding: "24px" }}>
<h1>MUI starter</h1>
<Button variant="contained">Hello MUI</Button>
</div>
</ThemeProvider>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Повна картина для фронтендера
Material UI — це не просто бібліотека кнопок, а ціла дизайн-система для React-застосунку.
На trainee-рівні тобі важливо навчитись:
- ставити MUI у Vite та Next.js
- використовувати готові компоненти
- стилізувати через sx
- створювати кастомні компоненти через styled
- налаштовувати глобальну тему через createTheme
- робити overrides через components
- будувати консистентний UI без дублювання стилів
Що почитати далі
- ThemeProvider
- createTheme
- sx prop
- styled API
- palette
- typography
- breakpoints
- Dialog, Drawer, Snackbar
- MUI + React Hook Form
- MUI + Data Grid
Error Boundaries та Custom Hooks у React
Це дві дуже важливі теми для React-розробника початкового рівня.
Error Boundaries потрібні для того, щоб застосунок не падав повністю при помилці в окремій частині інтерфейсу.
Custom Hooks потрібні для того, щоб виносити повторювану логіку в окремі перевикористовувані функції.
Разом вони допомагають зробити код стабільнішим, чистішим і зручнішим для підтримки.
Що ти повинен зрозуміти в першу чергу
- Що таке Error Boundary і навіщо він потрібен
- Які помилки Error Boundary ловить, а які ні
- Чому Error Boundary зазвичай пишуть через class component
- Що таке fallback UI
- Що таке custom hook
- Чому custom hook починається з
use - Як custom hook використовує вбудовані React hooks
- Як організовувати hooks у Vite та Next.js
- Типові помилки при роботі з Error Boundaries і custom hooks
Error Boundaries — основа
Error Boundary — це спеціальний React-компонент, який перехоплює помилки в дочірньому дереві компонентів під час рендеру, у lifecycle-методах і в конструкторах дочірніх компонентів.
Якщо якась частина UI падає, Error Boundary може показати запасний інтерфейс замість того, щоб зламати весь екран.
Навіщо потрібен Error Boundary
- Щоб одна зламана частина інтерфейсу не ламала весь застосунок
- Щоб показати користувачу зрозуміле повідомлення про помилку
- Щоб залогувати помилку у консоль або сервіс моніторингу
- Щоб ізолювати проблемну частину UI
Що Error Boundary ловить
- Помилки під час рендеру компонента
- Помилки в методах життєвого циклу class components
- Помилки в конструкторах дочірніх компонентів
Що Error Boundary НЕ ловить
- Помилки в event handlers
-
Помилки в асинхронному коді через
setTimeoutабо Promise - Помилки під час SSR на сервері
- Помилки всередині самого Error Boundary
Просте правило
Якщо помилка сталася під час побудови інтерфейсу, Error Boundary може її перехопити.
Якщо помилка сталася всередині кліку, запиту, таймера або
іншої зовнішньої логіки, її потрібно обробляти окремо через
try/catch, перевірки стану або обробку помилок
у запитах.
Базовий Error Boundary
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
errorMessage: "",
};
}
static getDerivedStateFromError(error) {
return {
hasError: true,
errorMessage: error.message,
};
}
componentDidCatch(error, errorInfo) {
// Логуємо помилку для дебагу або відправки в сервіс моніторингу
console.error("Помилка в дочірньому компоненті:", error);
console.error("Додаткова інформація:", errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>Щось пішло не так</h2>
<p>{this.state.errorMessage}</p>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
Пояснення до цього коду
-
hasErrorпоказує, чи зловив boundary помилку -
getDerivedStateFromErrorзмінює state і вмикає fallback UI -
componentDidCatchдає місце для логування помилок -
this.props.children— це вкладені компоненти, які boundary обгортає
Компонент, який спеціально падає
export default function BuggyComponent() {
// Штучно кидаємо помилку для демонстрації
throw new Error("Компонент зламався під час рендеру");
return <div>Цей текст ніколи не з'явиться</div>;
}
Використання Error Boundary
import ErrorBoundary from "./ErrorBoundary";
import BuggyComponent from "./BuggyComponent";
export default function App() {
return (
<div>
<h1>Мій застосунок</h1>
<ErrorBoundary>
<BuggyComponent />
</ErrorBoundary>
</div>
);
}
Чому Error Boundary пишуть через class component
Історично React Error Boundaries базуються на
lifecycle-методах
getDerivedStateFromError і
componentDidCatch.
Через це типовий boundary у React найчастіше реалізують саме як клас.
У звичайних функціональних компонентах немає прямого
еквівалента
componentDidCatch.
Краще оформлення fallback UI
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error, errorInfo) {
// Тут можна відправляти дані в Sentry, LogRocket або власний бекенд
console.error("Зловлена помилка:", error);
console.error("Інформація про компонент:", errorInfo);
}
handleReset = () => {
// Скидаємо стан boundary, щоб спробувати рендер ще раз
this.setState({
hasError: false,
});
};
render() {
if (this.state.hasError) {
return (
<section>
<h2>Сталася помилка в цій частині сторінки</h2>
<p>Спробуй перезавантажити блок ще раз.</p>
<button type="button" onClick={this.handleReset}>
Спробувати знову
</button>
</section>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
Де краще ставити Error Boundaries
- Навколо великих незалежних секцій інтерфейсу
- Навколо складних віджетів
- Навколо сторонніх компонентів або нестабільного UI
- Навколо сторінок або маршрутів
Приклад розумного розбиття
import ErrorBoundary from "./ErrorBoundary";
import Header from "./Header";
import Sidebar from "./Sidebar";
import DashboardWidgets from "./DashboardWidgets";
import ChatPanel from "./ChatPanel";
export default function App() {
return (
<>
<Header />
<ErrorBoundary>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary>
<DashboardWidgets />
</ErrorBoundary>
<ErrorBoundary>
<ChatPanel />
</ErrorBoundary>
</>
);
}
Антиприклади для Error Boundary
- Не треба обгортати кожен дрібний компонент окремо без потреби
-
Не треба думати, що boundary замінює
try/catch - Не треба ловити в boundary помилки з кнопок і асинхронних запитів
- Не треба тримати весь застосунок під одним boundary без причини
Помилка в event handler не ловиться boundary
export default function Example() {
function handleClick() {
// Цю помилку Error Boundary не перехопить
throw new Error("Помилка в обробнику кліку");
}
return (
<button type="button" onClick={handleClick}>
Натисни мене
</button>
);
}
Як обробляти такі помилки правильно
export default function Example() {
function handleClick() {
try {
// Будь-яка логіка, яка може зламатися
throw new Error("Помилка в обробнику кліку");
} catch (error) {
// Локальна обробка помилки в події
console.error("Помилка під час кліку:", error);
}
}
return (
<button type="button" onClick={handleClick}>
Натисни мене
</button>
);
}
Error Boundaries у Vite
У Vite немає спеціального файлового механізму для Error Boundaries.
Ти просто створюєш React-компонент boundary і самостійно обгортаєш ним потрібні частини дерева.
Базова структура у Vite
src/
components/
ErrorBoundary.jsx
BuggyWidget.jsx
pages/
HomePage.jsx
App.jsx
main.jsx
main.jsx для Vite
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
App.jsx для Vite
import ErrorBoundary from "./components/ErrorBoundary";
import HomePage from "./pages/HomePage";
export default function App() {
return (
<ErrorBoundary>
<HomePage />
</ErrorBoundary>
);
}
Коли цього достатньо у Vite
- Коли потрібно захистити окрему сторінку
- Коли потрібно ізолювати окремий віджет
- Коли потрібно показати fallback замість зламаного блоку
Error Boundaries у Next.js
У Next.js треба розділяти два основні сценарії:
- App Router
- Pages Router
Next.js App Router
В App Router є спеціальний файловий механізм для обробки неочікуваних помилок у сегменті маршруту.
Для цього використовують файл error.js або
error.tsx.
Структура для App Router
app/
dashboard/
error.jsx
page.jsx
layout.jsx
Приклад app/dashboard/error.jsx
"use client";
export default function DashboardError({ error, reset }) {
// Можна залогувати або показати повідомлення користувачу
console.error("Помилка сегмента dashboard:", error);
return (
<div>
<h2>Не вдалося завантажити dashboard</h2>
<p>Сталася неочікувана помилка.</p>
<button type="button" onClick={() => reset()}>
Спробувати ще раз
</button>
</div>
);
}
Що важливо знати про error.jsx у Next.js
-
Це Client Component, тому потрібен рядок
"use client"; -
Компонент отримує
errorтаresetяк props - Він спрацьовує для свого route segment і вкладених компонентів цього сегмента
Сторінка, яка падає в App Router
export default function DashboardPage() {
// Демонстраційна помилка
throw new Error("Dashboard зламався");
return <div>Dashboard</div>;
}
Next.js Pages Router
У Pages Router немає такого самого сегментного механізму, як
error.js в App Router.
Там ти зазвичай використовуєш звичайний React Error Boundary вручну.
Структура Pages Router
pages/
_app.jsx
profile.jsx
components/
ErrorBoundary.jsx
pages/_app.jsx
import ErrorBoundary from "../components/ErrorBoundary";
export default function MyApp({ Component, pageProps }) {
return (
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
);
}
Висновок по Error Boundaries у Vite і Next.js
- У Vite ти все робиш вручну через React boundary
-
У Next.js App Router є вбудований підхід через
error.js - У Next.js Pages Router зазвичай знову використовується звичайний React Error Boundary
Custom Hooks — основа
Custom hook — це звичайна JavaScript-функція, яка використовує React hooks всередині себе і дозволяє перевикористовувати логіку між компонентами.
Він не створює нову можливість React, а просто допомагає красиво винести повторювану поведінку.
Навіщо потрібні custom hooks
- Щоб не дублювати один і той самий код у багатьох компонентах
- Щоб спростити компоненти
- Щоб винести побічні ефекти й стан у окрему логіку
- Щоб зробити код читабельнішим і простішим для тестування
Головні правила custom hooks
- Назва має починатися з
use - Всередині можна використовувати інші React hooks
- Hook треба викликати лише на верхньому рівні
- Не можна викликати hooks у циклах, умовах або вкладених функціях
- Custom hook не повинен рендерити JSX, він повертає дані, функції або стан
Найпростіший custom hook
import { useState } from "react";
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
function toggle() {
setValue(function(prevValue) {
return !prevValue;
});
}
return {
value,
toggle,
setValue,
};
}
Використання useToggle
import { useToggle } from "./hooks/useToggle";
export default function Example() {
const { value, toggle } = useToggle(false);
return (
<div>
<p>Стан: {value ? "Увімкнено" : "Вимкнено"}</p>
<button type="button" onClick={toggle}>
Перемкнути
</button>
</div>
);
}
Чим custom hook кращий за копіювання коду
Без hook ти будеш щоразу заново писати
useState, toggle і однакову
логіку.
З hook ти виносиш це в одне місце і перевикористовуєш.
Приклад useLocalStorage
import { useEffect, useState } from "react";
export function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(function() {
try {
const item = window.localStorage.getItem(key);
if (item !== null) {
return JSON.parse(item);
}
return initialValue;
} catch (error) {
// Якщо щось пішло не так під час читання, повертаємо дефолтне значення
console.error("Помилка читання localStorage:", error);
return initialValue;
}
});
useEffect(
function() {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
// Якщо запис не вдався, просто логуємо помилку
console.error("Помилка запису в localStorage:", error);
}
},
[key, storedValue]
);
return [storedValue, setStoredValue];
}
Використання useLocalStorage
import { useLocalStorage } from "./hooks/useLocalStorage";
export default function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage("theme", "light");
function handleChangeTheme() {
setTheme(function(prevTheme) {
return prevTheme === "light" ? "dark" : "light";
});
}
return (
<div>
<p>Поточна тема: {theme}</p>
<button type="button" onClick={handleChangeTheme}>
Змінити тему
</button>
</div>
);
}
Приклад useDebounce
import { useEffect, useState } from "react";
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
function() {
const timerId = setTimeout(function() {
setDebouncedValue(value);
}, delay);
return function() {
// Очищаємо таймер, щоб не було зайвих оновлень
clearTimeout(timerId);
};
},
[value, delay]
);
return debouncedValue;
}
Використання useDebounce для пошуку
import { useEffect, useState } from "react";
import { useDebounce } from "./hooks/useDebounce";
export default function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 700);
useEffect(
function() {
if (!debouncedQuery.trim()) {
return;
}
// Тут можна робити запит на сервер
console.log("Відправляємо запит:", debouncedQuery);
},
[debouncedQuery]
);
return (
<div>
<input
type="text"
value={query}
onChange={function(event) {
setQuery(event.target.value);
}}
placeholder="Пошук..."
/>
</div>
);
}
Приклад usePrevious
import { useEffect, useRef } from "react";
export function usePrevious(value) {
const ref = useRef();
useEffect(
function() {
// Зберігаємо попереднє значення після рендеру
ref.current = value;
},
[value]
);
return ref.current;
}
Використання usePrevious
import { useState } from "react";
import { usePrevious } from "./hooks/usePrevious";
export default function Counter() {
const [count, setCount] = useState(0);
const previousCount = usePrevious(count);
return (
<div>
<p>Поточне значення: {count}</p>
<p>Попереднє значення: {previousCount}</p>
<button
type="button"
onClick={function() {
setCount(function(prevCount) {
return prevCount + 1;
});
}}
>
Збільшити
</button>
</div>
);
}
Приклад useFetch без бібліотек
import { useEffect, useState } from "react";
export function useFetch(url) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(
function() {
const controller = new AbortController();
async function loadData() {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error("Не вдалося отримати дані");
}
const result = await response.json();
setData(result);
} catch (error) {
// Ігноруємо помилку скасування запиту
if (error.name === "AbortError") {
return;
}
setError(error);
} finally {
setIsLoading(false);
}
}
loadData();
return function() {
// Скасовуємо запит при розмонтуванні
controller.abort();
};
},
[url]
);
return {
data,
isLoading,
error,
};
}
Використання useFetch
import { useFetch } from "./hooks/useFetch";
export default function UsersList() {
const { data, isLoading, error } = useFetch(
"https://jsonplaceholder.typicode.com/users"
);
if (isLoading) {
return <p>Завантаження...</p>;
}
if (error) {
return <p>Помилка: {error.message}</p>;
}
return (
<ul>
{data?.map(function(user) {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
);
}
Комбінування custom hooks
Один custom hook може використовувати інший. Це нормально і дуже зручно.
Приклад composable hook
import { useEffect } from "react";
import { useLocalStorage } from "./useLocalStorage";
export function usePersistedTheme() {
const [theme, setTheme] = useLocalStorage("theme", "light");
useEffect(
function() {
document.body.dataset.theme = theme;
},
[theme]
);
return {
theme,
setTheme,
};
}
Структура hooks у звичайному React-проєкті
src/
hooks/
useToggle.js
useDebounce.js
useLocalStorage.js
useFetch.js
usePrevious.js
components/
pages/
Custom Hooks у Vite
У Vite custom hooks працюють як у звичайному React-проєкті без особливих додаткових правил.
Типова структура у Vite
src/
hooks/
useToggle.js
useFetch.js
components/
Search.jsx
ThemeSwitcher.jsx
App.jsx
Імпорт hook у Vite
import { useToggle } from "./hooks/useToggle";
export default function App() {
const { value, toggle } = useToggle();
return (
<button type="button" onClick={toggle}>
{value ? "ON" : "OFF"}
</button>
);
}
Custom Hooks у Next.js
У Next.js custom hooks теж працюють добре, але треба пам’ятати про різницю між Server Components і Client Components, якщо ти використовуєш App Router.
Головне правило для Next.js App Router
Якщо твій custom hook використовує useState,
useEffect, useRef, доступ до
window, localStorage або DOM API,
такий hook повинен використовуватися тільки в Client
Components.
Приклад hook для client-side логіки
import { useEffect, useState } from "react";
export function useWindowWidth() {
const [width, setWidth] = useState(0);
useEffect(function() {
function handleResize() {
setWidth(window.innerWidth);
}
handleResize();
window.addEventListener("resize", handleResize);
return function() {
window.removeEventListener("resize", handleResize);
};
}, []);
return width;
}
Використання в Next.js App Router
"use client";
import { useWindowWidth } from "@/hooks/useWindowWidth";
export default function ClientWidget() {
const width = useWindowWidth();
return <p>Ширина вікна: {width}px</p>;
}
Структура hooks у Next.js
app/
page.jsx
dashboard/
page.jsx
hooks/
useToggle.js
useWindowWidth.js
components/
ClientWidget.jsx
Що з Pages Router у Next.js
Якщо ти використовуєш Pages Router, то custom hooks поводяться майже так само, як у звичайному React-проєкті.
Але якщо hook звертається до window або
localStorage, це потрібно робити тільки на
клієнті, зазвичай через useEffect або перевірку
середовища.
Приклад безпечного доступу до localStorage у Next.js
import { useEffect, useState } from "react";
export function useClientToken() {
const [token, setToken] = useState("");
useEffect(function() {
const savedToken = window.localStorage.getItem("token") || "";
setToken(savedToken);
}, []);
return token;
}
Практичний приклад: Error Boundary + Custom Hook разом
Часто їх використовують разом: hook повертає стан, а boundary страхує непередбачуваний рендер.
Hook для отримання користувача
import { useEffect, useState } from "react";
export function useUser(userId) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(
function() {
let isMounted = true;
async function loadUser() {
try {
setIsLoading(true);
setError(null);
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/" + userId
);
if (!response.ok) {
throw new Error("Не вдалося завантажити користувача");
}
const data = await response.json();
if (isMounted) {
setUser(data);
}
} catch (error) {
if (isMounted) {
setError(error);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
loadUser();
return function() {
isMounted = false;
};
},
[userId]
);
return {
user,
error,
isLoading,
};
}
Компонент, який використовує hook
import { useUser } from "./hooks/useUser";
export default function UserCard() {
const { user, error, isLoading } = useUser(1);
if (isLoading) {
return <p>Завантаження...</p>;
}
if (error) {
return <p>Помилка: {error.message}</p>;
}
if (!user) {
return <p>Користувача не знайдено</p>;
}
return (
<article>
<h3>{user.name}</h3>
<p>{user.email}</p>
</article>
);
}
Обгортання в Error Boundary
import ErrorBoundary from "./components/ErrorBoundary";
import UserCard from "./components/UserCard";
export default function App() {
return (
<ErrorBoundary>
<UserCard />
</ErrorBoundary>
);
}
Типові помилки при написанні custom hooks
- Називають hook без префікса
use - Викликають hook всередині умови
- Повертають JSX замість логіки
- Кладуть занадто багато різної логіки в один hook
- Не очищають side effects
- Не думають про SSR і доступ до
window
Поганий приклад
import { useState } from "react";
export function toggleThing() {
// Так не можна, бо це hook без префікса use
const [value, setValue] = useState(false);
return {
value,
setValue,
};
}
Правильний приклад
import { useState } from "react";
export function useToggleThing() {
const [value, setValue] = useState(false);
return {
value,
setValue,
};
}
Ще одна типова помилка
import { useState } from "react";
export function useExample(condition) {
if (condition) {
// Так не можна, hooks не можна викликати в умовах
const [value, setValue] = useState(0);
return { value, setValue };
}
return null;
}
Правильний варіант
import { useState } from "react";
export function useExample(condition) {
const [value, setValue] = useState(0);
return {
value,
setValue,
condition,
};
}
Як організувати файли
Варіант для Vite
src/
components/
ErrorBoundary.jsx
hooks/
useToggle.js
useDebounce.js
useFetch.js
useLocalStorage.js
pages/
HomePage.jsx
App.jsx
main.jsx
Варіант для Next.js App Router
app/
error.jsx
layout.jsx
page.jsx
dashboard/
error.jsx
page.jsx
components/
ErrorBoundary.jsx
ClientWidget.jsx
hooks/
useToggle.js
useWindowWidth.js
useLocalStorage.js
Коли що використовувати
Коли потрібен Error Boundary
- Коли компонент складний і може падати під час рендеру
- Коли потрібно ізолювати проблемну секцію інтерфейсу
- Коли потрібен fallback UI замість білого екрану
Коли потрібен custom hook
- Коли одна й та сама логіка повторюється в кількох компонентах
- Коли компонент стає занадто великим
- Коли треба винести роботу зі станом, ефектами або API
Шпаргалка по темі
- Error Boundary ловить помилки рендеру в дочірньому дереві, але не ловить помилки в click handlers та async-коді
- Класичний Error Boundary у React зазвичай пишеться як class component
- У Vite boundary створюється вручну як звичайний React-компонент
-
У Next.js App Router існує спеціальний файл
error.js -
Custom hook — це функція з префіксом
use, яка використовує React hooks усередині - Custom hooks допомагають перевикористовувати логіку, а не розмітку
- У Next.js App Router hooks з клієнтською логікою потрібно використовувати в Client Components
- Не викликай hooks у циклах, умовах і вкладених функціях
Міні-план вивчення для trainee
- Створи простий Error Boundary і обгорни ним один компонент
- Зроби компонент, який спеціально кидає помилку
- Додай fallback UI
- Напиши
useToggle - Напиши
useDebounce - Напиши
useLocalStorage - Збери невеликий приклад, де custom hook керує даними, а boundary страхує UI
- Повтори те саме окремо у Vite і в Next.js
Підсумок
Error Boundaries відповідають за стабільність інтерфейсу, коли щось ламається під час рендеру.
Custom Hooks відповідають за чисту архітектуру, коли логіка повторюється в різних компонентах.
Якщо запам’ятати одну ключову ідею, то вона така: Error Boundary захищає UI від падіння, а custom hook допомагає не дублювати логіку.