Strona główna / Artykuły / Narzędziownik dostosowanych hooki wielokrotnego użycia dla każdego nowego projektu React

Narzędziownik dostosowanych hooki wielokrotnego użycia dla każdego nowego projektu React

Odkryj starannie wyselekcjonowaną kolekcję dostosowanych hooków React – obejmujących przechowywanie danych, odczekiwanie przed działaniem, obsługę kliknięć oraz pobieranie danych – które eliminują powtarzalny kod szablonowy w nowych projektach.

3840 słów

Haki React stały się standardowym sposobem zarządzania stanem, efektami ubocznymi oraz logiką wielokrotnego użycia w nowoczesnych aplikacjach, ale znajomość wbudowanych haków to dopiero połowa sukcesu. Równie ważne jest posiadanie osobistego zestawu małych, dobrze przetestowanych, dostosowanych haków, które można włączyć do każdego nowego projektu, aby uniknąć ponownego tworzenia tych samych narzędzi od zera. Ten artykuł najpierw przedstawia zestaw haków, które warto mieć w swoim zestawie startowym, a następnie zagłębia się w to, jak faktycznie działają podstawowe interfejsy API haków, abyś rozumiał nie tylko to, co należy skopiować, ale także dlaczego zachowują się w określony sposób.

Gdy rozpoczynamy nowy projekt React, określone hooki niemal zawsze pojawiają się ponownie. Niektóre rozwiązują problemy z wydajnością, inne poprawiają doświadczenie użytkownika, a kilka po prostu zapobiegają ponownemu pisaniu tej samej logiki w każdym projekcie. W trakcie realizacji wielu projektów te narzędzia stają się standardowym zestawem wyjściowym: zamiast rozwiązywać ten sam problem od zera, wprowadzamy odpowiedni hook, dostosowujemy go w razie potrzeby i wracamy do tworzenia rzeczywistych funkcjonalności. Poniżej znajduje się dwanaście hooków odpowiadających temu opisowi.

Pierwszym z nich jest useLocalStorage – przydatny, ponieważ niemal każda aplikacja potrzebuje przechowywać dane między ponownymi załadunkami, czy to preferencje tematyczne, dane z formularza, czy inne ustawienia użytkownika.

import { useState } from "react";
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });  const updateValue = (newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };  return [value, updateValue];
}

Ten wzorzec jest często stosowany do przechowywania takich informacji jak preferencje trybu ciemnego, token autoryzacyjny lub dowolna ustawienie, które ma być pamiętane między wizytami.

Następnie jest useToggle, przeznaczony do typowego przypadku, gdy stan zmienia się między wartościami true a false.

import { useState } from "react";
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);  const toggle = () => setValue(v => !v);  return [value, toggle];
}

Łączy się doskonale z modalemami, menu rozwijanymi, paskami bocznymi oraz podobnymi elementami interfejsu, które można włączać i wyłączać.

useDebounce jest często używany przy polach wyszukiwania, gdy nie chcemy wysyłać żądań przy każdym naciśnięciu klawisza.

import { useState, useEffect } from "react";
function useDebounce(value, delay = 500) {
  const [debounced, setDebounced] = useState(value);  useEffect(() => {
    const timer = setTimeout(() => {
      setDebounced(value);
    }, delay);    return () => clearTimeout(timer);
  }, [value, delay]);  return debounced;
}

Opóźnienie aktualizacji do chwili przerwy w pisaniu zmniejsza liczbę niepotrzebnych wywołań API.

useWindowSize umożliwia układom responsywnym dostęp do aktualnych wymiarów obszaru widoku.

import { useState, useEffect } from "react";
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });  useEffect(() => {
    const resize = () =>
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });    window.addEventListener("resize", resize);    return () => window.removeEventListener("resize", resize);
  }, []);  return size;
}

usePrevious jest przydatny, gdy musisz porównać wartość z tą, jaka była podczas ostatniego renderowania.

import { useEffect, useRef } from "react";
function usePrevious(value) {
  const ref = useRef();  useEffect(() => {
    ref.current = value;
  }, [value]);  return ref.current;
}

Jest to szczególnie użyteczne w animacjach lub do wykrywania, kiedy coś faktycznie się zmieniło.

useClickOutside zamyka elementy interfejsu użytkownika, takie jak modale, gdy użytkownik kliknie gdziekolwiek poza nimi, co odpowiada intuicyjnym oczekiwaniom użytkowników dotyczącym zachowania tych komponentów.

import { useEffect } from "react";
function useClickOutside(ref, callback) {
  useEffect(() => {
    function handleClick(e) {
      if (ref.current && !ref.current.contains(e.target)) {
        callback();
      }
    }    document.addEventListener("mousedown", handleClick);    return () =>
      document.removeEventListener("mousedown", handleClick);
  }, [ref, callback]);
}

Dobrze sprawdza się w przypadku spadających list, okien powiadomień i menu nawigacyjnych w urządzeniach mobilnych.

useDocumentTitle utrzymuje tytuł karty przeglądarki zgodnie z aktualnym widokiem, co ułatwia nawigację i orientację.

import { useEffect } from "react";
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

Zamiast powtarzać ten sam efekt w wielu komponentach, wywołujesz tę funkcję tam, gdzie strona potrzebuje specjalnego tytułu.

useFetch umieszcza podstawowe pobieranie danych w ponownie używalnym hooku.

import { useState, useEffect } from "react";
function useFetch(url) {
  const [data, setData] = useState(null);  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData);
  }, [url]);  return data;
}

Dla projektów większych niż małe, narzędzia takie jak React Query lub SWR zazwyczaj są lepszym wyborem, ale ta lżejsza wersja wystarcza, by uruchomić małą aplikację.

useCopyToClipboard rozwiązuje problem powszechnie używanych przycisków kopiowania.

function useCopyToClipboard() {
  const copy = (text) => {
    navigator.clipboard.writeText(text);
  };
  return copy;
}

Świetnie nadaje się do udostępniania linków zaproszeniowych, kodów kuponowych lub kluczy API jednym kliknięciem.

useOnlineStatus umożliwia aplikacji reagowanie na przerwanie lub przywrócenie połączenia sieciowego.

import { useState, useEffect } from "react";
function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);  useEffect(() => {
    window.addEventListener("online", () => setOnline(true));
    window.addEventListener("offline", () => setOnline(false));
  }, []);  return online;
}

Choć jest to niewielka modyfikacja, znacząco poprawia doświadczenie użytkownika w sytuacjach niestabilnego połączenia.

useDarkMode obsługuje przełączanie między trybem ciemnym, którego oczekują większość użytkowników od nowoczesnych aplikacji.

import { useEffect } from "react";
function useDarkMode(enabled) {
  useEffect(() => {
    document.body.classList.toggle("dark", enabled);
  }, [enabled]);
}

Często jest używany razem z useLocalStorage, aby wybrany tryb pozostawał niezmieniony pomiędzy sesjami.

Narazie useTimeout sprawia, że praca z opóźnieniami jest znacznie uporządkowana w porównaniu z rozrzuconymi bezpośrednimi wywołaniami setTimeout w komponentach.

import { useEffect } from "react";
function useTimeout(callback, delay) {
  useEffect(() => {
    const timer = setTimeout(callback, delay);    return () => clearTimeout(timer);
  }, [callback, delay]);
}

Podoba się do powiadamień, ekranów startowych oraz wszelkich działań, które muszą zostać wykonyane po pewnym opóźnieniu.

We wielu projektach z użyciem Reacta jedna lekcja pozostaje niezmienna: nie musisz za każdym razem budować wszystkiego od zera. Posiadanie małej bazy dostępnych ponownie hooków przyspiesza rozwój, ułatwia czytanie komponentów i eliminuje powtarzający się kod. Żaden z tych dwunastu hooków nie jest szczególnie skomplikowany, ale razem oszczędzają one znaczną ilość czasu w trakcie realizacji projektu. W miarę rozwoju własnych projektów prawdopodobnie zgromadzisz podobną osobistą kolekcję – a ważne nie jest całkowita liczba hooków, jakie posiadasz, lecz to, czy rozwiązują one problemy, z którymi napotykasz się raz po raz. Mała funkcja pomocnicza, którą napiszesz dziś dla jednego projektu, często staje się narzędziem, którego używasz we wszystkich kolejnych projektach.

Zrozumienie osobistej biblioteki hooków jest przydatne w codziennej pracy, ale równie ważne jest zrozumienie mechanizmów leżących u ich podstaw — co rozwiązuje każdy z kluczowych hooków, kiedy go używać oraz jak zachowuje się podczas różnych renderowań. To głębsze zrozumienie sprawia, że korzystanie z hooków przestaje polegać na prostym kopiowaniu i wklejaniu, a staje się prawdziwą znajomością ich funkcjonowania.

Zanim pojawiły się hooki, komponenty funkcyjne nie mogły przechowywać stanu ani wykonywać efektów ubocznych, więc taka logika znajdowała się w komponentach klasowych z obiektem state oraz metodami cyklu życia:

class Counter extends React.Component {
  state = {
    count: 0
  };

  increment = () => {
    this.setState({
      count: this.state.count + 1
    });
  };

  render() {
    return (
      <button onClick={this.increment}>
        {this.state.count}
      </button>
    );
  }
}

Dzięki hookom ten sam komponent staje się zwykłą funkcją, która bezpośrednio wywołuje useState:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

wersja funkcyjna jest zazwyczaj łatwiejsza do odczytania i ponownego użycia.

Hooki podlegają dwóm zasadom. Po pierwsze, zawsze należy je wywoływać na najwyższym poziomie — nigdy wewnątrz warunków, pętli czy funkcji nawiasowych. Unikaj tego:

if (isLoggedIn) {
  useEffect(() => {
    // ❌
  }, []);
}


for (...) {
  useState(0); // ❌
}

Zamiast tego utrzymaj wywołanie Hook bezwarunkowe i umieść logikę warunkową później:

function Component() {
  const [count, setCount] = useState(0);

  if (count > 10) {
    // Normal conditional logic is fine
  }

  return ...;
}

Ta zasada istnieje, ponieważ React śledzi stan według kolejności wywołań, a nie nazw. Przy dwóch wywołaniach useState:

function Component() {
  const [count] = useState(0);
  const [name] = useState("Salim");

  return ...;
}

React ustawia je według pozycji:

Hook #1 → count
Hook #2 → name

Jeśli pierwsze wywołanie stanie się warunkowe:

if (condition) {
  useState(0);
}

useState("Salim");

kolejność może ulec zmianie pomiędzy renderami:

Render 1:
Hook #1 → count
Hook #2 → name

Render 2:
Hook #1 → name

Gdy to się stanie, React nie może już powiązać zapisanego stanu z odpowiednim wywołaniem, więc kolejność musi pozostać niezmienna przy każdym renderze.

useState pozwala komponentowi pamiętać wartość między renderami:

const [count, setCount] = useState(0);

Zwraca parę:

count     → current state
setCount  → state update function

Prosty licznik pokazuje ten wzorzec:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Kliknięcie przycisku uruchamia tę sekwencję:

Click
 ↓
setCount()
 ↓
React schedules update
 ↓
Component renders again
 ↓
New state is calculated
 ↓
DOM is updated if necessary

Stan nie jest zwykłą zmienną jak let count = 0, ponieważ nie przetrwałaby ponownego uruchomienia funkcji. React przechowuje go poza komponentem i dostarcza odpowiednią wartość przy każdym renderowaniu:

Render 1
count = 0

Render 2
count = 1

Render 3
count = 2

Komponent jest ponownie wykonywany, ale React zachowuje stan pomiędzy tymi wykonywaniami.

To ma znaczenie, gdy stan jest aktualizowany kilka razy w jednym obsługiwanym zdarzeniu:

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

Jeśli render rozpoczął się od count równego 0, wszystkie trzy linie korzystają z tego samego stanu w tym momencie, co daje:

setCount(1)
setCount(1)
setCount(1)

Zamiast trzech rzeczywistych zwiększeń. Rozwiązaniem jest aktualizacja funkcjonalna:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

Teraz React aplikuje każdą aktualizację kolejno, ponieważ każdy mechanizm aktualizacji otrzymuje najnowszą wartość — co jest przydatne, gdy nowy stan zależy od starego stanu.

useEffect synchronizuje komponent z czymś poza Reactem:

useEffect(() => {
  // synchronization logic
}, [dependencies]);

Typowe przypadki obejmują wywołania API, timery, słuchacze zdarzeń, WebSockets, inne API przeglądarki, subskrypcje oraz biblioteki od stron trzecich. Przykład:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  return <h1>{user?.name}</h1>;
}

Ten efekt jest zsynchronizowany z userId; gdy się zmienia, efekt musi zostać ponownie uruchomiony.

To zachowanie pochodzi z tablicy zależności. Przy założeniu:

useEffect(() => {
  console.log(count);
}, [count]);

React porównuje zależności koncepcyjnie:

Previous dependencies
        ↓
New dependencies
        ↓
Compare
        ↓
Changed?

Jeśli się różnią, efekt jest uruchamiany ponownie:

Previous: [1]
New:      [2]

→ Effect needs to run

Jeśli się pokrywają, jest pomijany:

Previous: [2]
New:      [2]

→ Effect can be skipped

Porównanie odbywa się za pomocą Object.is, a nie głębokiej równości.

Efekty mogą zwracać funkcję czyszczenia, która jest uruchamiana przed następnym efektem oraz przy dezmontowaniu komponentu:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

Czyszczenie jest ważne w przypadkach takich jak:

Timers
Event listeners
Subscriptions
WebSocket connections
Abortable requests

Gdy zmieniają się zależności, React usuwa stary efekt przed uruchomieniem nowego; usuwa go również, gdy komponent jest demontowany.

Pole wyszukiwania pokazuje to w praktyce — każde naciśnięcie klawisza może wywołać żądanie:

react
react hooks
react performance

Ponieważ przestarzała odpowiedź może nadpisać nowszą, należy anulować poprzednie żądanie:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, {
    signal: controller.signal
  })
    .then(res => res.json())
    .then(setResults)
    .catch(error => {
      if (error.name !== "AbortError") {
        console.error(error);
      }
    });

  return () => {
    controller.abort();
  };
}, [query]);

Życiowy cykl wygląda w ten sposób:

query changes
     ↓
cleanup previous effect
     ↓
abort previous request
     ↓
start new request

Prawdziwe pola wyszukiwania zazwyczaj łączą to z mechanizmem opóźniania wywołań.

Częstym błędem jest używanie useEffect dla wartości, które można bezpośrednio obliczyć:

const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

Ponieważ fullName pochodzi bezpośrednio z istniejących wartości, można całkowicie pominąć efekt i stan:

const fullName = `${firstName} ${lastName}`;

Wersja z efektem dodaje niepotrzebną synchronizację oraz dodatkowe renderowanie. Zasada ogólna: jeśli można coś obliczyć podczas renderowania, nie potrzeba do tego efektu.

useRef dostarcza stabilny obiekt, którego właściwość .current pozostaje niezmienna pomiędzy renderowaniami, bez wywoływania ponownego renderowania w momencie jej zmiany:

const ref = useRef(initialValue);

Częstym zastosowaniem jest odnoszenie się do węzła DOM, na przykład ustawienie fokusa na polu wprowadzenia:

function Input() {
  const inputRef = useRef(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>
        Focus
      </button>
    </>
  );
}

useRef jest również przydatny do przechowywania wartości zmiennych, które w ogóle nie powinny pojawiać się w renderowanym wyniku, takich jak identyfikator timera:

const timerRef = useRef(null);

Następnie przypisuje się do niego wartości w taki sam sposób, jak przy przypisywaniu do dowolnej właściwości obiektu:

timerRef.current = setInterval(...);

Zmiana wartości

ref.current

sama w sobie nie wywołuje ponownego renderowania. Porównaj te dwa modele myślenia:

useState
→ update → render

useRef
→ mutate .current → no render

To sprawia, że useRef jest odpowiedni dla wartości, które muszą przetrwać pomiędzy renderowaniami, ale nie powinny wpływać na to, co jest wyświetlane na ekranie.

useMemo przyjmuje inne podejście: zapisuje w pamięci wynik obliczenia, a nie referencję DOM.

const result = useMemo(() => {
  return expensiveCalculation(data);
}, [data]);

Pomyśl o tym tak: useMemo = zapisywanie w pamięci wyniku obliczenia. Typowym przypadkiem użycia jest filtrowanie listy tylko wtedy, gdy dane, na których się ona opiera, faktycznie ulegają zmianie:

function ProductList({ products, search }) {
  const filteredProducts = useMemo(() => {
    return products.filter(product =>
      product.name
        .toLowerCase()
        .includes(search.toLowerCase())
    );
  }, [products, search]);

  return <ProductGrid products={filteredProducts} />;
}

Jeśli aktualizuje się jakiś niepowiązany element stanu, React może po prostu zwrócić wcześniej obliczoną wartość, o ile zależności pozostają takie same.

Koncepcyjnie React przechowuje małą informację przypisaną do wywołania hooka:

Hook
 ├── memoized value
 └── dependencies

Podczas pierwszego renderowania:

data = A

calculate(A)
 ↓
result = X

Podczas późniejszego renderowania, jeśli data nadal jest wartością A, porównanie zależności przejdzie pomyślnie i React ponownie wykorzysta X bez ponownego obliczania. Dopiero gdy data stanie się czymś w rodzaju B, React przeprowadzi ponownie obliczenia.

Mimo to useMemo łatwo nadużywać. Otaczanie czegoś w ten sposób:

const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

rzadko jest tego warte — obliczenia są błahe, a sama memoizacja nie jest bezkosztowa; dodaje obciążenie i wymaga dodatkowego kodu do zrozumienia. Używaj useMemo, gdy obliczenia są rzeczywiście kosztowne, gdy potrzebujesz stabilnego odniesienia do obiektu lub tablicy, albo gdy analiza wydajności (lub logiczne rozważania) pokazują, że faktycznie pomaga.

useCallback to odpowiedni narzędzie do odniesień do funkcji zamiast ich wartości.

const handleClick = useCallback(() => {
  doSomething();
}, []);

To ma znaczenie, ponieważ zwykłe funkcje są tworzone na nowo przy każdym renderowaniu:

function Parent() {
  const handleClick = () => {
    console.log("clicked");
  };

  return <Child onClick={handleClick} />;
}

Przy pierwszym renderowaniu handleClick wskazuje na jedną instancję funkcji; przy drugim renderowaniu wskazuje na inną, więc obie nie są równe, mimo że wykonują tę samą czynność.

To nierówność staje się problemem, gdy komponent potomny jest otoczony funkcją React.memo:

const Child = React.memo(function Child({ onClick }) {
  console.log("Child rendered");

  return (
    <button onClick={onClick}>
      Click
    </button>
  );
});

a komponent nadrzędny wygląda w ten sposób:

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        {count}
      </button>

      <Child onClick={handleClick} />
    </>
  );
}

Gdy zmienia się wartość count:

Parent renders
      ↓
handleClick reference remains stable
      ↓
React.memo checks Child props
      ↓
onClick unchanged
      ↓
Child can skip rendering

Bез użycia useCallback nowo utworzony referencja funkcji jest postrzegana jako „nowa” przez komponent potomny zastosowany z mechanizmem memoizacji, co powoduje jego ponowną renderzację, mimo że nic istotnego się nie zmieniło.

Mimo to useCallback nie powinien być stosowany automatycznie do każdej funkcji. Zanim otoczymy jakiś handler tą funkcją, sprawdźmy, czy rzeczywiście jest to potrzebne – na przykład:

React.memo child
Dependency array
Expensive downstream computation

Jeśli żadne z tych warunków nie jest spełnione, zwykłe ponowne utworzenie funkcji zazwyczaj nie stanowi problemu.

Różnica pomiędzy tymi dwoma hookami polega na tym, co one memoizują:

useMemo
→ memoizes a VALUE

useCallback
→ memoizes a FUNCTION

Conceptually:

useMemo(() => calculateValue(), deps);

useCallback(() => doSomething(), deps);

Przechodząc od hakenów skupionych na wydajności do tych o charakterze strukturalnym, API Context rozwiązuje inne problemy: przekazywanie danych przez wiele warstw zagnieżdżonych komponentów. Bez niego wartość taka jak aktualny użytkownik musiałaby przejść przez każdy pośredni poziom:

App
 ↓
Navbar
 ↓
UserMenu
 ↓
Profile
 ↓
Avatar

co skutkuje czymś w rodzaju:

<App user={user} />
<Navbar user={user} />
<UserMenu user={user} />
<Profile user={user} />
<Avatar user={user} />

Ten wzorzec przekazywania właściwości przez komponenty, które w inny sposób ich nie potrzebują, nazywany jest prop drilling.

Ustawienie kontekstu zaczyna się od wywołania tworzącego obiekt:

const UserContext = createContext(null);

Następnie podaje się wartość za pomocą dostawcy:

function App() {
  const user = {
    name: "Salim",
    role: "Developer"
  };

  return (
    <UserContext.Provider value={user}>
      <Dashboard />
    </UserContext.Provider>
  );
}

i odczytuje się ją tam, gdzie jest potrzebna:

function Profile() {
  const user = useContext(UserContext);

  return <h1>Hello {user.name}</h1>;
}

Profile nie musi już otrzymywać user przekazywanego przez każdy komponent pośredni.

Powszechnym przykładem z rzeczywistego życia jest tematyzacja:

const ThemeContext = createContext(null);

function App() {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Dashboard />
    </ThemeContext.Provider>
  );
}

Każdy komponent może wtedy bezpośrednio odczytać obecny temat:

function Button() {
  const { theme } = useContext(ThemeContext);

  return (
    <button className={theme}>
      Submit
    </button>
  );
}

tworząc strukturę przypominającą drzewo:

App
 │
 └── ThemeProvider
       │
       └── Dashboard
             │
             └── Button

Button pobiera wartość tematu bez konieczności przechodzenia przez kolejne właściwości. Warto podkreślić, że Context nie jest automatycznie pełnym rozwiązaniem do zarządzania stanem. Odpowiada na jedno konkretne pytanie: jak sprawić, by wartość była dostępna dla komponentów znajdujących się głęboko w drzewie? Nie dostarcza dodatkowych narzędzi — selektorów, middleware’u, ustrukturyzowanych aktualizacji — które oferuje dedykowana biblioteka. W przypadku bardziej złożonych potrzeb dotyczących stanu można skorzystać z:

Redux
Zustand
Jotai
Reducer + Context

Context najlepiej postrzegać jako mechanizm dystrybucji wartości, a nie menedżer stanu.

Ma on również wpływ na renderowanie. Biorąc pod uwagę:

<ThemeContext.Provider value={theme}>

Gdy tylko zmieni się wartość danego kontekstu, komponenty, które ją wykorzystują, mogą ponownie się renderować. Kontekst nie zapobiega temu w sposób magiczny — umieszczenie dużego obiektu, który często się zmienia, w szeroko wykorzystywanym kontekście może faktycznie powodować niepotrzebną pracę. Lepszymi kandydatami na kontekst są wartości, które zmieniają się rzadko, takie jak:

Theme
Locale
Authentication information
Feature flags
Application configuration

Custom Hooks umożliwiają pakowanie logicznych struktur wielokrotnie używalnych, zbudowanych na bazie wbudowanych hooków. Minimalny przykład:

function useCounter() {
  const [count, setCount] = useState(0);

  function increment() {
    setCount(c => c + 1);
  }

  return {
    count,
    increment
  };
}

Używane w ten sposób:

function Counter() {
  const { count, increment } = useCounter();

  return (
    <button onClick={increment}>
      Count: {count}
    </button>
  );
}

Prawdziwa wartość tutaj polega na wielokrotnym wykorzystaniu logiki, a nie markupu.

Należy to podkreślić: custom Hooks dzielą się logiką, a nie stanem. Jeśli dwa oddzielne komponenty każdy wywołuje ten sam custom Hook:

const counterA = useCounter();
const counterB = useCounter();

nie dzielą się jednym obiektem count. Każde wywołanie ma swój własny, niezależny stan. Koncepcyjnie:

Component A
 └── useCounter
      └── useState → State A

Component B
 └── useCounter
      └── useState → State B

Własny Hook umożliwia integrację behawioru, a nie wspólnego magazynu danych.

Jako konkretny przykład wyobraźmy sobie aplikację, która musi pokazać, czy użytkownik ma obecnie połączenie sieciowe. Zamiast kopiować logikę obsługi zdarzeń przeglądarki we każdym komponencie, który jej potrzebuje, można ją zamknąć w własnym Hooku:

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine
  );

  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;
}

Każdy komponent, który potrzebuje informacji o stanie połączenia, może teraz korzystać z nich bezpośrednio:

function Navbar() {
  const isOnline = useOnlineStatus();

  return (
    <span>
      {isOnline ? "🟢 Online" : "🔴 Offline"}
    </span>
  );
}

Inny komponent może użyć tego samego Hooku, aby całkowicie zmienić swój wygląd:

function Checkout() {
  const isOnline = useOnlineStatus();

  if (!isOnline) {
    return <p>You are offline.</p>;
  }

  return <PaymentForm />;
}

Podobny wzorzec stosuje się w przypadku opóźniania szybko zmieniających się danych wejściowych:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

Używany w funkcji wyszukiwania w ten sposób:

function Search() {
  const [query, setQuery] = useState("");

  const debouncedQuery = useDebounce(query, 500);

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

    // Search API
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

Który powoduje następującą sekwencję zdarzeń:

User types
    ↓
query changes
    ↓
500ms wait
    ↓
debouncedQuery changes
    ↓
API request

Te elementy budulcowe są przeznaczone do łączenia się ze sobą, a nie do używania osobno:

                 React Component
                       │
       ┌───────────────┼────────────────┐
       │               │                │
   useState        useEffect        useContext
       │               │                │
    UI state       External systems   Shared data
       │
       └───────────────┐
                       │
                 Custom Hook
                       │
             ┌─────────┼─────────┐
             ▼         ▼         ▼
         useState   useEffect   useMemo

Na przykład niestandardowy Hook useProducts() może wewnętrznie polegać na:

useState
+
useEffect
+
useMemo

podczas pobierania stanu autoryzacji z useContext. Ponowne renderowanie odbywa się według przewidywalnej ścieżki:

State / Props / Context change
          ↓
      React update
          ↓
       Render
          ↓
   Reconciliation
          ↓
       Commit
          ↓
      DOM update
          ↓
      Browser paint

przy czym każdy Hook pełni odrębną rolę, co zostało podsumowane tutaj:

useState
→ provides state + schedules updates

useMemo
→ calculates/reuses values during render

useCallback
→ calculates/reuses function references during render

useContext
→ reads context during render

useEffect
→ synchronizes with external systems after commit

Literatura pokrewna

  • React Components 101: Budowanie ponownie używalnych i łatwych w utrzymaniu elementów interfejsu — Dowiedz się, dlaczego dzielenie interfejsów na małe komponenty React poprawia ich ponowne wykorzystanie, czytelność oraz współpracę zespołu, a następnie stwórz swój pierwszy funkcjonalny komponent.
  • Dlaczego catch () wywołuje SyntaxError w JavaScript — Dowiedz się, dlaczego pusta lista parametrów catch całkowicie zakłóca analizę kodu w JavaScript, i zobacz dwa poprawne pod względem gramatyki sposoby napisania bloku catch bez parametrów.
  • Przestań synchronizować stan z useEffect: bezpieczniejszy wzorzec Reacta — Dowiedz się, dlaczego używanie useEffect do synchronizacji stanu pochodnego powoduje sytuacje konkurencyjne i dodatkowe renderowania, oraz jak zastąpić to metodą wyprowadzania stanu w czasie renderowania i atrybutem key.