Strona główna / Artykuły

Wybór odpowiedniego narzędzia React: Derive, Handle, Fetch, Defer czy Effect

Przewodnik decyzyjny dotyczący zastępowania refleksywnych wywołań useEffect wartościami pochodnymi, obsługą zdarzeń, warstwą danych, funkcją useTransition, funkcją useMemo oraz API React 19.

Wydawca: REACTAPP.TOP8 min czytania1896 słów

Źródła: Stop Using `useEffect` for Everything: 7 React Patterns That Make Your Code Simpler

Wiele komponentów React gromadzi wywołania useEffect służące do pobierania danych, reagowania na propy, obliczania wartości oraz utrzymywania spójności stanu. Kilka miesięcy później otwierasz plik i znajdujesz cztery efekty, trzy zmienne stanu pochodnego, dwa flagi ładowania oraz błąd, którego nikt nie potrafi wyjaśnić. Ten przewodnik proponuje jedno pytanie do zadania przed napisaniem jakiegokolwiek hooka: „Czy naprawdę potrzebny jest tu efekt?”, oraz siedem konkretnych wzorców do zastosowania, gdy odpowiedź brzmi „nie”.

Wartości pochodne powinny znajdować się w funkcji render, a nie w stanie

Najczęstszym nadużyciem jest przechowywanie czegoś, co można obliczyć. Załóżmy, że pokazujesz produkty przefiltrowane według zapytania wyszukiwawczego. Typowym pierwszym krokiem jest dodanie zmiennej stanu dla wyniku:

const [filteredProducts, setFilteredProducts] = useState([]);

a następnie efektu, który ponownie ją oblicza za każdym razem, gdy zmieniają się dane wejściowe:

useEffect(() => {
  setFilteredProducts(
    products.filter(product =>
      product.name.toLowerCase().includes(query.toLowerCase())
    )
  );
}, [products, query]);

To działa, ale filteredProducts nie jest niezależnym stanem. Jest to czysta funkcja wartości, które już posiadasz:

products
+
query
=
filteredProducts

Przechowywanie go oznacza, że React najpierw wyświetla przestarzałe wyniki, następnie uruchamia Effect i ponownie je wyświetla. Lepiej oblicz go bezpośrednio w ciele komponentu:

const filteredProducts = products.filter(product =>
  product.name.toLowerCase().includes(query.toLowerCase())
);

W rezultacie nie masz żadnej dodatkowej zmiennej stanu, żadnego Effectu, niczego do synchronizacji oraz drugiego renderowania tylko po to, by uzyskać wynik. Jeśli filtrowanie jest rzeczywiście kosztowne, na przykład w przypadku bardzo dużej listy, otocz tę samą wyrażenie funkcją useMemo:

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

Memo to szczegół. Ważna jest zasada: wartość pochodząca z propsów lub stanu nie musi mieć swojego miejsca w stanie.

Działania użytkownika powinny znajdować się w obsługiwaczach zdarzeń

Kolejny wzorzec kieruje kliknięcie przez stan, aby Effect mógł na nie zareagować. Najpierw flaga:

const [shouldSave, setShouldSave] = useState(false);

Następnie efekt, który obserwuje flagę, oraz obsługa, która ją ustawia:

useEffect(() => {
  if (shouldSave) {
    saveUser();
    setShouldSave(false);
  }
}, [shouldSave]);function handleSave() {
  setShouldSave(true);
}

Nie ma powodu do takiego objazdu. Kliknięcie to moment, w którym akcja powinna się wykonać, a już masz funkcję, która uruchamia się w tym momencie:

function handleSave() {
  saveUser();
}

Porównaj oba sposoby działania. Wersja z flagą przechodzi przez kilka kroków, zanim cokolwiek zostanie zapisane, a następnie wywołuje kolejne renderowanie w celu przywrócenia stanu:

Click
  ↓
State update
  ↓
Render
  ↓
Effect
  ↓
saveUser()
  ↓
Another state update
  ↓
Render

Wersja bezpośrednia składa się z jednego kroku:

Click
  ↓
saveUser()

Zasada ogólna brzmi tak, że zdarzenia są obsługiwane w obsługach zdarzeń, a efekty istnieją po to, aby synchronizować się z systemami spoza React, a nie po to, by maskować zwykłą logikę zdarzeń.

Kopiuj właściwości do stanu tylko wtedy, gdy potrzebujesz dwóch wartości

To wydaje się nieszkodliwe. Komponent przechowuje własną kopię nazwy:

const [name, setName] = useState('');

a efekt nadpisuje tę kopię za każdym razem, gdy właściwość się zmienia:

useEffect(() => {
  setName(user.name);
}, [user]);

Teraz istnieją dwa źródła prawdy, i w każdym momencie trzeba zdecydować, które z nich jest poprawne:

user.name
     +
name

Jeśli jedyne, co robisz, to wyświetlasz wartość, odczytuj ją bezpośrednio z właściwości:

return <h2>{user.name}</h2>;

Istnieje uzasadniona wyjątek: formularz edycji, który celowo przechowuje tymczasową, edytowalną kopię danych serwera aż do momentu zapisania lub anulowania przez użytkownika:

Server data
    ↓
Edit form
    ↓
User changes fields
    ↓
Save / Cancel

Tutaj stan lokalny jest poprawny, ponieważ projekt rzeczywiście stanowi odrębną wartość z własnym cyklem życia. Lekcją nie jest „nigdy nie kopiować właściwości do stanu”, lecz „wiedzieć, dlaczego tworzy się drugie źródło prawdy”. W przypadku formy, sformatowanie projektu poprzez przypisanie formie key powiązanego z identyfikatorem rekordu jest często czystsze niż synchronizacja z Effectem; nasz artykuł o zastępowaniu efektów synchronizujących stan szczegółowo omawia tę technikę.

Pozwól warstwie danych zajmować się pobieraniem danych

Ładowanie danych ma tendencję do rozprzestrzeniania się po różnych Effectach. Zaczyna się od jednej żądania przy uruchomieniu:

useEffect(() => {
  fetchUser();
}, []);

a potem przeradza się w serię Effectów, z których każdy pobiera dane dla tego samego użytkownika:

useEffect(() => {
  fetchOrders();
}, [userId]);useEffect(() => {
  fetchNotifications();
}, [userId]);useEffect(() => {
  fetchPermissions();
}, [userId]);

W tym momencie komponent staje się warstwą orkiestracji API. W większych aplikacjach właśnie tutaj opłaca się zastosowanie dedykowanej solucji do pobierania danych. W zależności od używanego stacku może to być:

  • TanStack Query
  • SWR
  • możliwości pobierania danych dostępne w Twoim frameworku
  • Server Components, tam gdzie są stosowane

w przeciwnym razie musisz ręcznie zajmować się każdym z tych aspektów dla każdej prośby:

loading
error
data
refetch
cache
retry
stale data
race conditions

Warstwa danych zajmuje się tym wszystkim za Ciebie. Koncepcyjnie zapytanie określa, jakie dane są potrzebne i w jaki sposób je uzyskać, a także zwraca aktualny stan:

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
});

Klucz zapytania służy jednocześnie jako klucz cache’u, dzięki czemu komponenty żądające tych samych danych użytkownika korzystają z jednego żądania, a zmiana wartości userId spowoduje pobranie nowego użytkownika, bez możliwości użycia przestarzałych odpowiedzi. Komponent wraca do renderowania interfejsu zamiast funkcjonować jako mała biblioteka sieciowa.

Oznaczaj niepilne aktualizacje za pomocą useTransition

Zastanówmy się nad polem wyszukiwania, które steruje kosztowną tabelą. Przy pojedynczej aktualizacji stanu każde naciśnięcie klawisza zmusza tabelę do ponownego renderowania, zanim pole wprowadzania danych będzie mogło pokazać następny znak:

function handleChange(e) {
  setQuery(e.target.value);
}

Rozwiązaniem jest podzielenie wartości na dwie części: to, co wyświetla się w polu wprowadzania, oraz to, według czego tabela filtrowa:

const [inputValue, setInputValue] = useState('');
const [query, setQuery] = useState('');

Wartość pola wprowadzania jest aktualizowana normalnie, natomiast aktualizacja zapytania jest otoczona funkcją startTransition:

const [isPending, startTransition] = useTransition();function handleChange(e) {
  const value = e.target.value;  setInputValue(value);  startTransition(() => {
    setQuery(value);
  });
}

React teraz traktuje te dwie aktualizacje inaczej. Wpisywanie tekstu jest pilne:

Typing
↓
Urgent

podczas gdy filtrowanie i ponowne renderowanie mogą być przerwane i opóźnione:

Filtering/rendering
↓
Can wait

Przejście nie sprawia, że wolny kod stanie się szybszy; zmienia planowanie tak, aby interfejs pozostał responsywny, a nowe naciśnięcie klawisza mogło przerwać trwające renderowanie. isPending pozwala zasygnalizować, że tabela nadąża za potrzebami.

Mierz przed zapisywaniem w pamięci

Odwrotnym błędem jest optymalizacja instynktowna. Często zaczyna się od jednego obliczenia zapisanego w pamięci:

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

a następnie od mapowania zapisanego w pamięci:

const users = useMemo(() => {
  return data.map(...);
}, [data]);

potem od definicji kolumny zapisanej w pamięci:

const columns = useMemo(() => [...], []);

i opcji zapisanych w pamięci:

const options = useMemo(() => [...], []);

Niedługo każda wartość w komponencie zostanie otoczona takimi mechanizmami, a jednak nikt nigdy nie sprawdzi, czy coś jest wolne. useMemo pomaga przy kosztownych operacjach i zapewnia stabilne referencje dla elementów zapisanych w pamięci, ale optymalizacja powinna następować po wykonaniu pętli:

Problem
  ↓
Measure
  ↓
Find bottleneck
  ↓
Optimize
  ↓
Measure again

a nie jako skrót:

Component feels complicated
  ↓
Add useMemo

W przypadku tanich obliczeń memoizacja dodaje jedynie nakład pracy związany z odczytem oraz tablice zależności, które mogą stać się przestarzałe. Profiler w React DevTools pokazuje, czy to rzeczywiście komponent stanowi wąskie gardło.

Wykorzystaj prymitywy dodane w React 19

Wiele nawyków z wcześniejszych wersji React ma teraz prostsze alternatywy. Przez lata przekazywanie ref do własnego komponentu oznaczało jego owinięcie w forwardRef. W React 19 komponenty funkcyjne otrzymują ref jako zwykłą właściwość:

function CustomInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

Kolejnym przykładem jest optymistyczna interfejs użytkownika. Zamiast ręcznie dodawać tymczasowy element do stanu i pamiętać o jego późniejszym usunięciu lub zastąpieniu:

setTodos(prev => [
  ...prev,
  {
    id: 'temp',
    text,
    pending: true
  }
]);

można użyć useOptimistic, który tworzy tymczasową wersję interfejsu na podstawie rzeczywistego stanu:

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
  todos,
  (state, todo) => [...state, todo]
);

Wartość optymistyczna jest wyświetlana, gdy trwa asynchroniczna operacja, a po jej zakończeniu wraca się do rzeczywistych danych todos, więc nie ma żadnego tymczasowego elementu do usunięcia. Ma być używana wewnątrz akcji lub przejścia; przypadki krawędziowe związane z nieudanymi żądaniami są omówione w naszym artykule o trybach awarii useOptimistic rollback.

Dla identyfikatorów, które łączą etykiety z polami wprowadzania danych w celu poprawy dostępności, należy użyć wbudowanego hooka:

const id = useId();

zamiast generować go losowo:

const id = `input-${Math.random()}`;

Losowy identyfikator zmienia się przy każdym renderowaniu i różni się między serwerem a klientem, co zakłóca proces hydratacji oraz powiązanie etykiet z danymi. React ciągle dodaje elementy prymitywne, które bezpośrednio wyrażają intencję, więc unikaj rozwiązywania bieżących problemów za pomocą wzorców, które są starsze od nich.

Lista kontrolna przed dodaniem hooka

Dobra znajomość Reacta obejmuje również wiedzę o tym, kiedy nie używać hooków. Komponent nie staje się lepszy od użycia wszystkich z nich:

useEffect()
useMemo()
useCallback()
useRef()
useState()

Celuj w przepływ danych, który jest łatwy do zrozumienia. Zanim sięgniesz po hooka, określ problem i dopasuj do niego odpowiedni narzędzie.

Dla danych pochodnych:

Calculate it.

Dla działania użytkownika:

Handle the event.

Dla synchronizacji z czymś poza Reactem, takim jak subskrypcja, timer lub komponent niebędący częścią Reacta:

Consider useEffect.

Dla odczytu z zewnętrznego magazynu danych:

Consider useSyncExternalStore or a state library.

Dla aktualizacji, która jest ważna, ale nie pilna:

Consider useTransition.

Dla obliczenia, które może być kosztowne:

Measure first, then consider useMemo.

A gdy React już dostarcza odpowiedni element do wykonania tej czynności:

Use the primitive instead of rebuilding it yourself.

Główne wnioski

  • Najzdrowsze komponenty zazwyczaj mają najmniej niepotrzebnych hooków, a nie najwięcej.
  • Zamień pytanie „Gdzie mogę użyć useEffect?” na „Czy naprawdę potrzebny jest tu Effect?”. W większości przypadków odpowiedź wskazuje na obliczenia w czasie renderowania, obsługę zdarzeń lub biblioteki do przechowywania danych.
  • Nauč się najpierw, jaki problem rozwiązuje każda nowa API React, zanim przejdziesz do jej składni.
  • Czasami najskuteczniejszą optymalizacją w komponencie jest usunięcie hooka zamiast jego dodanie.