React Typescript

Кастомні хуки: повторне використання та композиція реактивної логіки

Створення власних хуків для витягування повторюваної логіки: useOnlineStatus, useFetch, useLocalStorage, useDebounce. Правила композиції хуків, типізація TypeScript та архітектурні патерни для масштабованих застосунків.

Кастомні хуки: повторне використання та композиція реактивної логіки

🔗 Офіційний референс: Повторне використання логіки з кастомними хуками (Reusing Logic with Custom Hooks)

🎯 Мета лекції

  • Зрозуміти філософію кастомних хуків як абстракції для повторного використання поведінки.
  • Опанувати правила іменування, композиції та ізоляції стану між викликами хуків.
  • Навчитися створювати типізовані кастомні хуки для типових сценаріїв (fetch, localStorage, debounce).
  • Застосувати архітектурні патерни для організації хуків у великих проєктах.

🔑 Ключові терміни

  • Кастомний хук (Custom Hook): TypeScript-функція, що починається з префікса use та може викликати інші хуки React.
  • Ізоляція стану (State Isolation): кожен виклик кастомного хука отримує власний незалежний екземпляр стану.
  • Композиція хуків (Hook Composition): побудова складних хуків шляхом комбінування простіших хуків.
  • Інверсія контролю (Inversion of Control): компонент не знає, як реалізована логіка хука — він просто отримує результат.

Чому існують кастомні хуки?

У попередніх лекціях ми вивчили вбудовані хуки React: useState, useEffect, useRef, useReducer, useContext. Вони дозволяють компонентам мати стан, синхронізуватися із зовнішніми системами та обмінюватися даними.

Проте, коли проєкт росте, ви починаєте помічати повторювані патерни реактивної логіки:

  • Відстеження статусу мережевого з'єднання (navigator.onLine).
  • Завантаження даних з API із обробкою станів loading, error, data.
  • Збереження стану у localStorage із автоматичною синхронізацією.
  • Відкладений (debounced) виклик функції при введенні тексту.
  • Анімації, таймери, підписки на браузерні події.

Якщо копіювати цю логіку у кожен компонент, де вона потрібна, код стане громіздким, важко тестованим та схильним до помилок.

Фундаментальна ідея кастомних хуків:Якщо компоненти відповідають за повторне використання UI-структури (JSX-розмітки), то кастомні хуки відповідають за повторне використання поведінки (стану + ефектів + обчислень).Кастомний хук інкапсулює складну реактивну логіку та надає простий API для компонентів, які його використовують.

Правила створення кастомних хуків

React накладає лише дві суворі вимоги на кастомні хуки:

Правило 1: Ім'я функції завжди починається з префікса use

// ✅ ПРАВИЛЬНО
function useOnlineStatus() { ... }
function useFetch(url: string) { ... }
function useDebounce<T>(value: T, delay: number) { ... }

// ❌ НЕПРАВИЛЬНО
function getOnlineStatus() { ... } // Не починається з use
function fetchData(url: string) { ... } // Не починається з use

Префікс use — це не конвенція, а технічна вимога. Лінтер React Hooks перевіряє, що функції, які починаються з use, дотримуються правил хуків (виклик лише на верхньому рівні, не всередині умов/циклів). Без цього префікса лінтер не зможе валідувати ваш код.

Правило 2: Хук може викликати інші хуки React

Всередині кастомного хука можна вільно використовувати:

  • Вбудовані хуки: useState, useEffect, useRef, useMemo, useCallback, useContext, тощо.
  • Інші кастомні хуки: useOnlineStatus, useFetch, useLocalStorage.
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine); // ✅ useState

  useEffect(() => { // ✅ useEffect
    function handleOnline() { setIsOnline(true); }
    function handleOffline() { setIsOnline(false); }

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return isOnline;
}
Обмеження:Кастомні хуки підкоряються тим самим правилам, що й вбудовані хуки:
  • ❌ Не можна викликати хуки всередині умов, циклів або вкладених функцій.
  • ❌ Не можна викликати хуки після раннього повернення (return).
  • ✅ Хуки завжди мають викликатися у фіксованому порядку при кожному рендері.
React покладається на порядок викликів хуків, щоб зв'язати стан із конкретним хуком. Порушення порядку призведе до непередбачуваних багів.

Базовий приклад: useOnlineStatus — відстеження мережевого з'єднання

Почнемо з найпростішого прикладу — хука, що підписується на браузерні події online та offline для відстеження статусу з'єднання.

Реалізація хука

import { useState, useEffect } from "react";

export function useOnlineStatus(): boolean {
  // Початковий стан — поточний статус з'єднання
  const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);

  useEffect(() => {
    // Обробники подій
    function handleOnline() {
      setIsOnline(true);
    }

    function handleOffline() {
      setIsOnline(false);
    }

    // Підписка на події window
    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    // Очищення підписок
    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []); // Порожній масив — підписка створюється один раз

  return isOnline;
}

Використання у компоненті

function StatusIndicator() {
  const isOnline = useOnlineStatus(); // Простий виклик хука

  return (
    <div className={`status ${isOnline ? "online" : "offline"}`}>
      {isOnline ? "🟢 Онлайн" : "🔴 Офлайн"}
    </div>
  );
}
Переваги кастомного хука:
  1. Інкапсуляція: Логіка роботи з navigator.onLine та браузерними подіями прихована всередині хука. Компонент не знає деталей реалізації.
  2. Повторне використання: Будь-який компонент може викликати useOnlineStatus() — не потрібно копіювати код підписок.
  3. Тестованість: Хук можна протестувати ізольовано, не рендерячи компонент.
  4. Читабельність: Компонент виражає намір на високому рівні абстракції: «покажи статус з'єднання», а не «підпишись на події, оновлюй стан, не забудь відписатися».

Ізоляція стану: кожен виклик хука має власний стан

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

function Dashboard() {
  const isOnline1 = useOnlineStatus(); // Власний стан isOnline
  const isOnline2 = useOnlineStatus(); // Інший незалежний стан isOnline

  // isOnline1 та isOnline2 — це дві різні змінні стану
  // Вони будуть мати однакове значення (true/false), бо обидві
  // читають navigator.onLine, але це окремі виклики useState всередині

  return (
    <div>
      <p>Індикатор 1: {isOnline1 ? "Онлайн" : "Офлайн"}</p>
      <p>Індикатор 2: {isOnline2 ? "Онлайн" : "Офлайн"}</p>
    </div>
  );
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Компонент Dashboard" #F1F5F9 {
  
  rectangle "Виклик 1: useOnlineStatus()" #DBEAFE {
    rectangle "useState(navigator.onLine)" as State1 #DCFCE7
    rectangle "useEffect(...)" as Effect1 #FEF3C7
    State1 --> Effect1
    note right of State1
      Власний стан: isOnline1
      Незалежна підписка на події
    end note
  }
  
  rectangle "Виклик 2: useOnlineStatus()" #DBEAFE {
    rectangle "useState(navigator.onLine)" as State2 #DCFCE7
    rectangle "useEffect(...)" as Effect2 #FEF3C7
    State2 --> Effect2
    note right of State2
      Власний стан: isOnline2
      Окрема незалежна підписка на події
    end note
  }
}

note bottom of "Компонент Dashboard"
  React розрізняє виклики хуків за **порядком виклику**.
  Перший useState всередині useOnlineStatus
  прив'язаний до першого слота стану компонента,
  другий useState — до другого слота.
end note

@enduml

Якщо потрібно ділитися станом між компонентами, використовуйте:

  • Context API (useContext) для глобального стану.
  • Підйом стану (lifting state up) до спільного батьківського компонента.
  • Зовнішні стор-менеджери (Redux, Zustand), які ми вивчимо у наступних лекціях.

Типізований хук завантаження даних: useFetch

Один із найпоширеніших сценаріїв — завантаження даних з API. Створимо узагальнений (generic) хук із підтримкою TypeScript та скасування запитів через AbortController.

Реалізація useFetch з повною типізацією

import { useState, useEffect } from "react";

interface UseFetchResult<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

export function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Створюємо AbortController для скасування запиту
    const controller = new AbortController();
    const { signal } = controller;

    async function fetchData(): Promise<void> {
      try {
        setIsLoading(true);
        setError(null);

        const response = await fetch(url, { signal });

        if (!response.ok) {
          throw new Error(`HTTP помилка ${response.status}: ${response.statusText}`);
        }

        const result: T = await response.json();
        setData(result);
      } catch (err) {
        // Ігноруємо AbortError — це нормальне скасування
        if (err instanceof Error && err.name !== "AbortError") {
          setError(err.message);
        }
      } finally {
        // Оновлюємо isLoading лише якщо запит не було скасовано
        if (!signal.aborted) {
          setIsLoading(false);
        }
      }
    }

    fetchData();

    // Функція очищення: скасовує запит при розмонтуванні або зміні URL
    return () => {
      controller.abort();
    };
  }, [url]); // Ефект перезапускається при зміні URL

  return { data, isLoading, error };
}

Використання useFetch у компоненті

interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useFetch<User>(
    `https://api.example.com/users/${userId}`
  );

  if (isLoading) {
    return <div className="spinner">Завантаження...</div>;
  }

  if (error) {
    return <div className="error">Помилка: {error}</div>;
  }

  if (!user) {
    return <div>Користувача не знайдено</div>;
  }

  return (
    <div className="user-card">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
Чому useFetch краще за звичайний useEffect у компоненті:
  1. DRY-принцип: Логіка fetch не дублюється у кожному компоненті.
  2. Безпека: AbortController автоматично скасовує застарілі запити — запобігає race conditions.
  3. Типізація: TypeScript знає структуру data завдяки дженерику <User>.
  4. Стандартизація: Усі компоненти обробляють стани loading, error, data однаково.

Хук синхронізації з localStorage: useLocalStorage

Створимо хук, що зберігає стан у localStorage та автоматично синхронізує його між вкладками браузера.

Реалізація useLocalStorage

import { useState, useEffect } from "react";

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // Ініціалізація стану: спочатку читаємо з localStorage
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.error(`Помилка читання з localStorage (ключ: ${key}):`, error);
      return initialValue;
    }
  });

  // Функція оновлення стану з записом у localStorage
  const setValue = (value: T | ((prev: T) => T)) => {
    try {
      // Підтримка функціонального оновлення
      const valueToStore = value instanceof Function ? value(storedValue) : value;

      setStoredValue(valueToStore);

      // Записуємо у localStorage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Помилка запису у localStorage (ключ: ${key}):`, error);
    }
  };

  // Синхронізація між вкладками браузера
  useEffect(() => {
    function handleStorageChange(event: StorageEvent) {
      if (event.key === key && event.newValue !== null) {
        try {
          setStoredValue(JSON.parse(event.newValue) as T);
        } catch (error) {
          console.error("Помилка парсингу події storage:", error);
        }
      }
    }

    window.addEventListener("storage", handleStorageChange);

    return () => {
      window.removeEventListener("storage", handleStorageChange);
    };
  }, [key]);

  return [storedValue, setValue];
}

Використання useLocalStorage

function ThemeSelector() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");

  function toggleTheme() {
    setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
  }

  return (
    <div className={`app ${theme}`}>
      <button onClick={toggleTheme}>
        {theme === "light" ? "🌙 Темна тема" : "☀️ Світла тема"}
      </button>
    </div>
  );
}
Магія синхронізації між вкладками:Коли користувач змінює тему в одній вкладці, подія storage автоматично спрацьовує в усіх інших відкритих вкладках того самого домену. Хук підписується на цю подію і оновлює стан — тема змінюється у всіх вкладках миттєво, без запитів до сервера.

Хук відкладеного виклику: useDebounce

При введенні тексту у пошукове поле не варто викликати API після кожного натискання клавіші — це створить сотні зайвих запитів. Debounce відкладає виконання функції до моменту, коли користувач припинить вводити текст на певний час.

Реалізація useDebounce

import { useState, useEffect } from "react";

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    // Створюємо таймер, що оновить debouncedValue через delay мілісекунд
    const timerId = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Очищаємо таймер при кожній зміні value (користувач продовжує вводити)
    return () => {
      clearTimeout(timerId);
    };
  }, [value, delay]);

  return debouncedValue;
}

Використання useDebounce для пошуку

function SearchBar() {
  const [searchQuery, setSearchQuery] = useState("");
  const debouncedQuery = useDebounce(searchQuery, 500); // 500мс затримка

  const { data: results, isLoading } = useFetch<string[]>(
    debouncedQuery
      ? `https://api.example.com/search?q=${debouncedQuery}`
      : ""
  );

  return (
    <div>
      <input
        type="text"
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder="Введіть запит..."
      />

      {isLoading && <p>Пошук...</p>}

      {results && (
        <ul>
          {results.map((item, i) => (
            <li key={i}>{item}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

participant "Користувач" as User
participant "searchQuery (стан)" as Query
participant "useDebounce" as Debounce
participant "debouncedQuery (стан)" as Debounced
participant "useFetch (API)" as API

User -> Query : Введення "r"
activate Query
Query -> Debounce : value = "r"
activate Debounce
Debounce -> Debounce : setTimeout(500ms)
note right: Таймер запущено

User -> Query : Введення "re" (через 100мс)
Query -> Debounce : value = "re"
Debounce -> Debounce : clearTimeout()\nsetTimeout(500ms)
note right: Старий таймер скасовано,\nновий таймер запущено

User -> Query : Введення "rea" (через 150мс)
Query -> Debounce : value = "rea"
Debounce -> Debounce : clearTimeout()\nsetTimeout(500ms)
note right: Знову скасовано та перезапущено

note over User
  Користувач припинив вводити
  (минуло 500мс без змін)
end note

Debounce -> Debounced : setDebouncedValue("rea")
activate Debounced
Debounced -> API : fetch("/search?q=rea")
activate API
API --> Debounced : Результати пошуку
deactivate API
deactivate Debounced
deactivate Debounce
deactivate Query

@enduml

Що відбувається:

  1. Користувач вводить "r", "re", "rea" швидко (менше 500мс між натисканнями).
  2. Кожне введення скидає таймер через clearTimeout.
  3. Коли користувач припиняє вводити на 500мс, таймер спрацьовує і оновлює debouncedQuery.
  4. useFetch бачить зміну debouncedQuery і виконує один запит до API.

Результат: Замість 3 запитів (r, re, rea) — лише 1 запит (rea).


Композиція хуків: побудова складних хуків із простих

Одна з найпотужніших властивостей кастомних хуків — їхня композиційність (composability). Складні хуки можна будувати, комбінуючи простіші хуки.

Приклад: useSearchWithDebounce — композиція useDebounce та useFetch

interface UseSearchResult<T> {
  query: string;
  setQuery: (value: string) => void;
  results: T | null;
  isLoading: boolean;
  error: string | null;
}

export function useSearchWithDebounce<T>(
  baseUrl: string,
  delay: number = 500
): UseSearchResult<T> {
  const [query, setQuery] = useState("");
  
  // Композиція хука useDebounce
  const debouncedQuery = useDebounce(query, delay);
  
  // Композиція хука useFetch
  const { data: results, isLoading, error } = useFetch<T>(
    debouncedQuery ? `${baseUrl}?q=${debouncedQuery}` : ""
  );

  return {
    query,
    setQuery,
    results,
    isLoading,
    error,
  };
}

Використання композитного хука

interface SearchResult {
  items: string[];
  total: number;
}

function SearchComponent() {
  const { query, setQuery, results, isLoading, error } =
    useSearchWithDebounce<SearchResult>("https://api.example.com/search");

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Пошук..."
      />

      {isLoading && <p>Пошук...</p>}
      {error && <p className="error">{error}</p>}

      {results && (
        <div>
          <p>Знайдено: {results.total}</p>
          <ul>
            {results.items.map((item, i) => (
              <li key={i}>{item}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}
Переваги композиції:
  1. Модульність: useSearchWithDebounce побудовано з двох незалежних хуків (useDebounce + useFetch), які можна використовувати окремо.
  2. Тестованість: Кожен простий хук можна протестувати ізольовано. Тест складного хука може покладатися на перевірені прості хуки.
  3. Підтримуваність: Якщо логіка useFetch змінюється (наприклад, додаємо кешування), усі композитні хуки автоматично отримають це покращення.

Хук відстеження розміру вікна: useWindowSize

Ще один корисний приклад — хук для responsive-дизайну, що відстежує ширину та висоту вікна браузера.

Реалізація useWindowSize

import { useState, useEffect } from "react";

interface WindowSize {
  width: number;
  height: number;
}

export function useWindowSize(): WindowSize {
  const [windowSize, setWindowSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // Підписка на зміну розміру вікна
    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return windowSize;
}

Використання для адаптивного UI

function ResponsiveLayout() {
  const { width } = useWindowSize();

  // Визначаємо breakpoints
  const isMobile = width < 768;
  const isTablet = width >= 768 && width < 1024;
  const isDesktop = width >= 1024;

  return (
    <div className="layout">
      {isMobile && <MobileNavigation />}
      {isTablet && <TabletNavigation />}
      {isDesktop && <DesktopNavigation />}

      <main>
        <p>Ширина вікна: {width}px</p>
        <p>Пристрій: {isMobile ? "Мобільний" : isTablet ? "Планшет" : "Десктоп"}</p>
      </main>
    </div>
  );
}
Оптимізація продуктивності:Подія resize може спрацьовувати десятки разів за секунду при перетягуванні вікна. Для продакшн-застосунків рекомендується комбінувати useWindowSize із useDebounce або використовувати throttle (обмеження частоти викликів).
function useWindowSizeOptimized(): WindowSize {
  const rawSize = useWindowSize();
  const debouncedSize = useDebounce(rawSize, 200); // Затримка 200мс
  return debouncedSize;
}

Практичні завдання для закріплення


Архітектурні патерни організації кастомних хуків

У великих проєктах кількість кастомних хуків може зрости до десятків або сотень. Важливо організувати їх структуровано.

Рекомендована структура папок

src/
├── hooks/
│   ├── api/
│   │   ├── useFetch.ts
│   │   ├── usePolling.ts
│   │   └── useMutation.ts
│   ├── browser/
│   │   ├── useOnlineStatus.ts
│   │   ├── useWindowSize.ts
│   │   └── useMediaQuery.ts
│   ├── form/
│   │   ├── useFormValidation.ts
│   │   ├── useAutoSave.ts
│   │   └── useDebounce.ts
│   ├── storage/
│   │   ├── useLocalStorage.ts
│   │   └── useSessionStorage.ts
│   └── index.ts  // Barrel export
├── components/
└── ...

Barrel export (src/hooks/index.ts):

// API хуки
export { useFetch } from "./api/useFetch";
export { usePolling } from "./api/usePolling";
export { useMutation } from "./api/useMutation";

// Browser хуки
export { useOnlineStatus } from "./browser/useOnlineStatus";
export { useWindowSize } from "./browser/useWindowSize";
export { useMediaQuery } from "./browser/useMediaQuery";

// Form хуки
export { useFormValidation } from "./form/useFormValidation";
export { useAutoSave } from "./form/useAutoSave";
export { useDebounce } from "./form/useDebounce";

// Storage хуки
export { useLocalStorage } from "./storage/useLocalStorage";
export { useSessionStorage } from "./storage/useSessionStorage";

Використання:

import { useFetch, useOnlineStatus, useLocalStorage } from "@/hooks";

Тестування кастомних хуків

Кастомні хуки можна тестувати ізольовано за допомогою @testing-library/react-hooks (для React ≤ 18) або @testing-library/react (для React 19+).

Приклад тесту для useDebounce

import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";

describe("useDebounce", () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it("повертає початкове значення негайно", () => {
    const { result } = renderHook(() => useDebounce("initial", 500));
    expect(result.current).toBe("initial");
  });

  it("оновлює значення після затримки", () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: "first", delay: 500 } }
    );

    expect(result.current).toBe("first");

    // Змінюємо value
    rerender({ value: "second", delay: 500 });

    // Значення ще не оновилося
    expect(result.current).toBe("first");

    // Прокручуємо час на 500мс
    act(() => {
      jest.advanceTimersByTime(500);
    });

    // Тепер значення оновилося
    expect(result.current).toBe("second");
  });

  it("скидає таймер при повторній зміні", () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: "a" } }
    );

    rerender({ value: "b" });
    act(() => { jest.advanceTimersByTime(300); });

    rerender({ value: "c" });
    act(() => { jest.advanceTimersByTime(300); });

    // Значення досі "a", бо таймер постійно скидався
    expect(result.current).toBe("a");

    // Тепер чекаємо повні 500мс
    act(() => { jest.advanceTimersByTime(500); });

    // Значення оновилося до останнього
    expect(result.current).toBe("c");
  });
});

Інтерактивна демонстрація: композиція хуків

React Кастомні хуки у дії
Файли проєкту
src
components
OnlineStatusDemo.tsx
SearchDemo.tsx
ThemeDemo.tsx
hooks
useDebounce.ts
useLocalStorage.ts
useOnlineStatus.ts
App.tsx
styles.css
localhost:3000

Збирання React застосунку…


Просунуті патерни: хуки з параметрами конфігурації

Коли хук стає складнішим, передача безлічі окремих параметрів стає незручною. У таких випадках використовують об'єкт конфігурації.

Приклад: useFetchWithConfig

interface FetchConfig {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  headers?: Record<string, string>;
  body?: any;
  enabled?: boolean; // Чи автоматично виконувати запит
  refetchInterval?: number; // Автоматичне повторне завантаження
  onSuccess?: (data: any) => void;
  onError?: (error: Error) => void;
}

interface UseFetchWithConfigResult<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
  refetch: () => void;
}

export function useFetchWithConfig<T>(
  url: string,
  config: FetchConfig = {}
): UseFetchWithConfigResult<T> {
  const {
    method = 'GET',
    headers = {},
    body,
    enabled = true,
    refetchInterval,
    onSuccess,
    onError,
  } = config;

  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  const [refetchTrigger, setRefetchTrigger] = useState<number>(0);

  useEffect(() => {
    if (!enabled) return;

    const controller = new AbortController();

    async function fetchData(): Promise<void> {
      try {
        setIsLoading(true);
        setError(null);

        const options: RequestInit = {
          method,
          headers: {
            'Content-Type': 'application/json',
            ...headers,
          },
          signal: controller.signal,
        };

        if (body && method !== 'GET') {
          options.body = JSON.stringify(body);
        }

        const response = await fetch(url, options);

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const result: T = await response.json();
        setData(result);

        if (onSuccess) {
          onSuccess(result);
        }
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
          if (onError) {
            onError(err);
          }
        }
      } finally {
        if (!controller.signal.aborted) {
          setIsLoading(false);
        }
      }
    }

    fetchData();

    // Автоматичне повторне завантаження
    let intervalId: NodeJS.Timeout | undefined;
    if (refetchInterval) {
      intervalId = setInterval(() => {
        fetchData();
      }, refetchInterval);
    }

    return () => {
      controller.abort();
      if (intervalId) {
        clearInterval(intervalId);
      }
    };
  }, [url, method, enabled, refetchTrigger, refetchInterval]);

  const refetch = () => {
    setRefetchTrigger((prev) => prev + 1);
  };

  return { data, isLoading, error, refetch };
}

Використання з конфігурацією

interface Product {
  id: number;
  name: string;
  price: number;
}

function ProductList() {
  const [enabled, setEnabled] = useState(true);

  const { data, isLoading, error, refetch } = useFetchWithConfig<Product[]>(
    'https://api.example.com/products',
    {
      enabled,
      refetchInterval: 30000, // Автооновлення кожні 30 секунд
      headers: {
        Authorization: 'Bearer token123',
      },
      onSuccess: (products) => {
        console.log(`Завантажено ${products.length} товарів`);
      },
      onError: (err) => {
        console.error('Помилка завантаження:', err);
      },
    }
  );

  return (
    <div>
      <div className="controls">
        <button onClick={() => setEnabled(!enabled)}>
          {enabled ? 'Вимкнути' : 'Увімкнути'} автозавантаження
        </button>
        <button onClick={refetch} disabled={!enabled}>
          🔄 Оновити вручну
        </button>
      </div>

      {isLoading && <p>Завантаження...</p>}
      {error && <p className="error">{error}</p>}

      {data && (
        <ul>
          {data.map((product) => (
            <li key={product.id}>
              {product.name} — {product.price} грн
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Переваги об'єкта конфігурації:
  1. Читабельність: Явні назви параметрів у виклику хука.
  2. Розширюваність: Легко додати нові опції без зміни сигнатури функції.
  3. Опціональність: Більшість параметрів мають значення за замовчуванням.
  4. TypeScript-підказки: IDE показує всі доступні опції при автодоповненні.

Хук для роботи з формами: useForm

Створимо універсальний хук для управління станом форми із валідацією.

Реалізація useForm

interface ValidationRule<T> {
  validate: (value: T) => boolean;
  message: string;
}

interface FieldConfig<T> {
  initialValue: T;
  rules?: ValidationRule<T>[];
}

interface UseFormResult<T> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  handleChange: (field: keyof T) => (value: any) => void;
  handleBlur: (field: keyof T) => () => void;
  handleSubmit: (onSubmit: (values: T) => void) => (e: React.FormEvent) => void;
  reset: () => void;
  isValid: boolean;
}

export function useForm<T extends Record<string, any>>(
  config: Record<keyof T, FieldConfig<T[keyof T]>>
): UseFormResult<T> {
  const initialValues = Object.keys(config).reduce((acc, key) => {
    acc[key as keyof T] = config[key as keyof T].initialValue;
    return acc;
  }, {} as T);

  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});

  const validateField = (field: keyof T, value: any): string | null => {
    const rules = config[field].rules;
    if (!rules) return null;

    for (const rule of rules) {
      if (!rule.validate(value)) {
        return rule.message;
      }
    }
    return null;
  };

  const handleChange = (field: keyof T) => (value: any) => {
    setValues((prev) => ({ ...prev, [field]: value }));

    const error = validateField(field, value);
    setErrors((prev) => ({
      ...prev,
      [field]: error || undefined,
    }));
  };

  const handleBlur = (field: keyof T) => () => {
    setTouched((prev) => ({ ...prev, [field]: true }));
  };

  const handleSubmit = (onSubmit: (values: T) => void) => (e: React.FormEvent) => {
    e.preventDefault();

    // Позначаємо всі поля як торкнуті
    const allTouched = Object.keys(config).reduce((acc, key) => {
      acc[key as keyof T] = true;
      return acc;
    }, {} as Partial<Record<keyof T, boolean>>);
    setTouched(allTouched);

    // Валідуємо всі поля
    const allErrors: Partial<Record<keyof T, string>> = {};
    let hasErrors = false;

    for (const field of Object.keys(config) as Array<keyof T>) {
      const error = validateField(field, values[field]);
      if (error) {
        allErrors[field] = error;
        hasErrors = true;
      }
    }

    setErrors(allErrors);

    if (!hasErrors) {
      onSubmit(values);
    }
  };

  const reset = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  };

  const isValid = Object.keys(errors).length === 0;

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    isValid,
  };
}

Використання useForm

interface LoginFormValues {
  email: string;
  password: string;
}

function LoginForm() {
  const { values, errors, touched, handleChange, handleBlur, handleSubmit, reset } =
    useForm<LoginFormValues>({
      email: {
        initialValue: '',
        rules: [
          {
            validate: (value) => value.length > 0,
            message: "Email є обов'язковим",
          },
          {
            validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
            message: 'Некоректний формат email',
          },
        ],
      },
      password: {
        initialValue: '',
        rules: [
          {
            validate: (value) => value.length >= 8,
            message: 'Пароль має містити мінімум 8 символів',
          },
        ],
      },
    });

  function onSubmit(data: LoginFormValues) {
    console.log('Відправка форми:', data);
    // Виклик API для входу
    reset();
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="field">
        <label>Email:</label>
        <input
          type="email"
          value={values.email}
          onChange={(e) => handleChange('email')(e.target.value)}
          onBlur={handleBlur('email')}
        />
        {touched.email && errors.email && (
          <span className="error">{errors.email}</span>
        )}
      </div>

      <div className="field">
        <label>Пароль:</label>
        <input
          type="password"
          value={values.password}
          onChange={(e) => handleChange('password')(e.target.value)}
          onBlur={handleBlur('password')}
        />
        {touched.password && errors.password && (
          <span className="error">{errors.password}</span>
        )}
      </div>

      <button type="submit">Увійти</button>
    </form>
  );
}

Найпоширеніші помилки при створенні кастомних хуків

Помилка 1: Порушення правил хуків

// ❌ ПОГАНО: хук викликається умовно
function useBadHook(shouldUse: boolean) {
  if (shouldUse) {
    const [state, setState] = useState(0); // Порушення правил!
    return state;
  }
  return null;
}

// ✅ ДОБРЕ: хук завжди викликається
function useGoodHook(shouldUse: boolean) {
  const [state, setState] = useState(0);
  
  if (!shouldUse) {
    return null;
  }
  
  return state;
}

Помилка 2: Забування мемоізації колбеків

// ❌ ПОГАНО: новий колбек при кожному рендері
function useBadCallback() {
  const [count, setCount] = useState(0);
  
  // Ця функція створюється заново при кожному рендері
  const increment = () => setCount(count + 1);
  
  return { count, increment };
}

// ✅ ДОБРЕ: мемоізований колбек
function useGoodCallback() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []); // Стабільне посилання
  
  return { count, increment };
}

Помилка 3: Повернення нестабільних об'єктів

// ❌ ПОГАНО: новий об'єкт при кожному рендері
function useBadReturn() {
  const [count, setCount] = useState(0);
  
  return {
    count,
    increment: () => setCount(count + 1),
  }; // Новий об'єкт щоразу — порушує референсну рівність
}

// ✅ ДОБРЕ: мемоізований об'єкт
function useGoodReturn() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);
  
  return useMemo(() => ({
    count,
    increment,
  }), [count, increment]);
}
Коли мемоізація справді потрібна:У більшості випадків повернення нового об'єкта не є проблемою — React ефективно обробляє зміни. Мемоізація важлива лише коли:
  1. Хук використовується у залежностях useEffect іншого компонента.
  2. Об'єкт передається як пропс у компонент, обгорнутий у React.memo.
  3. Виклик функції є дорогою операцією (складні обчислення).
Не оптимізуйте передчасно — додавайте useMemo/useCallback лише за наявності реальних проблем продуктивності.

Резюме: переваги кастомних хуків

🎯 Повторне використання

Витягуйте повторювану реактивну логіку (стан + ефекти) в окремі функції. Один хук може використовуватися у десятках компонентів без дублювання коду.

🔒 Інкапсуляція

Компоненти не знають деталей реалізації — вони отримують лише простий API. Зміна логіки хука не впливає на компоненти, що його використовують.

🧪 Тестованість

Хуки можна тестувати ізольовано без рендерингу компонентів. Тести перевіряють логіку поведінки, а не UI-взаємодію.

🧩 Композиційність

Складні хуки будуються з простих через композицію. useSearchWithDebounce комбінує useDebounce + useFetch без дублювання логіки.

📖 Читабельність

Компоненти виражають намір на високому рівні абстракції: const isOnline = useOnlineStatus() замість 20 рядків коду підписок.

⚡ Продуктивність

Правильно написані хуки оптимізують рендеринг через мемоізацію, уникають зайвих ефектів та автоматично очищають ресурси.

Підсумок лекції

✅ Ви навчилися

  • Створювати кастомні хуки за правилами React (префікс use, виклик на верхньому рівні).
  • Розуміти ізоляцію стану: кожен виклик хука має власний незалежний стан.
  • Будувати типізовані хуки для типових сценаріїв: useFetch, useLocalStorage, useDebounce, useOnlineStatus.
  • Застосовувати композицію хуків для побудови складних абстракцій із простих.
  • Організовувати хуки у великих проєктах через модульну структуру папок.
  • Тестувати кастомні хуки ізольовано без рендерингу компонентів.

🔮 Наступні кроки

У наступній лекції ми вийдемо за межі ядра React та почнемо вивчати екосистему React — бібліотеки та інструменти, які використовуються у реальних продакшн-застосунках. Першою темою буде React Router — стандартна бібліотека маршрутизації для створення односторінкових застосунків (SPA) із вкладеними маршрутами, параметрами URL, захищеними роутами та обробкою помилок.
Copyright © 2026