Матеріал

JavaScript: типи даних та масиви

Ця сторінка — навчальна шпаргалка з JavaScript. Вона створена для повторення базових тем перед вивченням React.

1. Типи даних у JavaScript

У JavaScript існує 8 основних типів даних.

Примітивні типи

  • string — рядки
  • number — числа
  • bigint — дуже великі цілі числа
  • booleantrue / 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
  • Оператори порівняння
  • Math
  • reduce
  • 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 спадкоємці:
    • CardPayment
    • PayPalPayment
  • Кожен тип платежу має власну реалізацію 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, замість onchangeonChange.

У 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. Практична схема мислення при написанні компонента

  1. Подумай, які дані компонент повинен отримати ззовні — це props
  2. Подумай, чи є в нього власний змінний стан — це useState
  3. Подумай, чи має він реагувати на дії користувача — це events
  4. Подумай, чи рендерить він список — тоді потрібен key
  5. Подумай, чи є побічні дії — тоді useEffect
  6. Подумай, чи є доступ до 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 — повинен бути коректним email
  • password — рядок мінімум з 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 — чи йде submit
  • touchedFields — які поля вже торкались

Блокування кнопки поки форма невалідна

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

Що краще запам'ятати в першу чергу

  1. Створюєш схему через Zod
  2. Підключаєш її через zodResolver
  3. Реєструєш поля через register
  4. Показуєш помилки через errors
  5. Обробляєш submit через handleSubmit
  6. Для 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.

Функціональні вимоги

  • Створити форму з такими полями:
    • Імʼя
    • Email
    • Пароль
    • Підтвердження пароля
    • Вік
  • Створити 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() — завершення worker
  • new 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 — вхідна точка React
  • src/App.jsx — головний компонент
  • index.html — HTML-шаблон
  • vite.config.js — конфігурація Vite
  • eslint.config.js — конфігурація ESLint
  • package.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 — загальний layout
  • public/ — статичні файли
  • next.config.js — конфігурація Next.js
  • package.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 params
  • useNavigate — програмна навігація
  • 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 — useRouter
  • pages/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

Міні-пам’ятка по порядку роботи

  1. Створити slice через createSlice
  2. Додати reducer у configureStore
  3. Підключити Provider
  4. У компоненті читати дані через useSelector
  5. Оновлювати дані через dispatch
  6. Для async запитів використати createAsyncThunk або RTK Query
  7. Для складнішого розширення додати 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/ — робота з API
  • hooks/ — кастомні хуки
  • 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.

Як це працює покроково

  1. Створюється ref через useRef(null).
  2. Ref передається в JSX через ref={inputRef}.
  3. Після рендеру React записує DOM-елемент у inputRef.current.
  4. У функції 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 — HOC
  • BaseComponent — звичайний компонент
  • 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. Порядок мислення при розв’язанні задачі

  1. Спочатку зроби просту робочу версію
  2. Потім розбий інтерфейс на компоненти через composition
  3. Подумай, чи потрібен Context, чи вистачить props
  4. Перевір, чи є реальна проблема з продуктивністю
  5. Лише після цього додавай 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.

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue
  6. getByAltText
  7. getByTitle
  8. 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();
  });
});

Що вчити у першу чергу

  1. describe, it, expect
  2. toBe, toEqual, toHaveBeenCalled
  3. render, screen
  4. getByRole, getByLabelText, getByText
  5. userEvent.click і userEvent.type
  6. queryBy і findBy
  7. jest.fn і jest.mock
  8. асинхронні тести
  9. тестування props, forms, callbacks
  10. тестування компонентів із 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

  1. Створи простий Error Boundary і обгорни ним один компонент
  2. Зроби компонент, який спеціально кидає помилку
  3. Додай fallback UI
  4. Напиши useToggle
  5. Напиши useDebounce
  6. Напиши useLocalStorage
  7. Збери невеликий приклад, де custom hook керує даними, а boundary страхує UI
  8. Повтори те саме окремо у Vite і в Next.js

Підсумок

Error Boundaries відповідають за стабільність інтерфейсу, коли щось ламається під час рендеру.

Custom Hooks відповідають за чисту архітектуру, коли логіка повторюється в різних компонентах.

Якщо запам’ятати одну ключову ідею, то вона така: Error Boundary захищає UI від падіння, а custom hook допомагає не дублювати логіку.

Завдання
Рішення