Strona główna / Artykuły / Lista kontrolna przeglądu kodu w React: stan pochodny, reduktory, Effects i Memo

Lista kontrolna przeglądu kodu w React: stan pochodny, reduktory, Effects i Memo

Naucz się rozpoznawać siedem powszechnych problemów w recenzjach Reacta, od duplikowanego stanu i ręcznego pobierania danych po niewłaściwe użycie efektów i nadmierne memoizowanie, oraz tego, co należy napisać zamiast tego.

1906 słów

Rozwijający z kilkuletnim doświadczeniem w React zazwyczaj wiedzą, jak funkcjonują propsy, jak działają hooki i dlaczego stan nie powinien być modyfikowany bezpośrednio. Mimo to podczas przeglądów kodu w ich pull requestach ciągle pojawiają się te same problemy: komponenty, które działają, ale są kruche, trudne do testowania, nadmiernie skomplikowane lub trudne do rozszerzenia. Przejście na wyższy poziom nie polega tyle na uczeniu się rzadko używanych API, ile raczej na lepszym zrozumieniu tego, gdzie znajduje się stan, do czego służą różne efekty oraz kiedy optymalizacja przynosi korzyści. Ten lista kontrolna omawia siedem często występujących problemów, używając jako przykładu listy zadań w TypeScript, i pokazuje wzorzec postępowania w każdym przypadku.

1. Stan, który powinien być wyprowadzany

Główną zasadą stanu w React jest utrzymywanie jednego źródła prawdy. Gdy ta sama informacja znajduje się w dwóch miejscach, kopiowanie jej ostatecznie doprowadzi do niezgodności.

Jak to wygląda

Lista zadań, która wymaga zarówno wszystkich pozycji, jak i tych ukończonych, często skutkuje posiadaniem dwóch tablic w stanie:

// ❌ BAD: Storing derived state in two separate places
const [todos, setTodos] = useState<Todo[]>([]);
const [completedTodos, setCompletedTodos] = useState<Todo[]>([]);

Każda zmiana musi być zatem wprowadzona dwa razy. Jeśli użytkownik zmieni nazwę zadania, a kod zaktualizuje todos, ale zapomni o completedTodos, interfejs pokaże stary tytuł w jednej liście, a nowy w drugiej.

Co napisać zamiast tego

Zachowaj jedną tablicę w stanie i oblicz ukończoną podgrupę podczas renderowania:

// ✅ GOOD: Single source of truth with derived state
const [todos, setTodos] = useState<Todo[]>([]);
// Derived value recalculated on every render (inexpensive computation)
const completedTodos = todos.filter((todo) => todo.completed);

Filtrowanie tablicy przy każdym renderowaniu jest kosztowne tylko przy typowych rozmiarach, a wygenerowana lista nigdy nie może stracić synchronizacji, ponieważ jest przeliczana na nowo z źródła za każdym razem. Jeśli obliczenia stają się naprawdę kosztowne, na przykład przy filtrowaniu dziesiątek tysięcy pozycji, otocz je funkcją useMemo; w przeciwnym razie zwykła wyrażenie będzie jaśniejsze.

2. Ręczne pobieranie danych w useEffect

Ręczne wywołania fetch wewnątrz useEffect wydają się proste, ale często prowadzą do sytuacji konkurencji, nie posiadają mechanizmu cache’owania i wymagają ręcznego zarządzania stanem ładowania oraz błędów w każdym komponencie.

Jak to wygląda

Ten efekt ładuje listę raz i używa flagi isMounted, aby nie zmieniać stanu po rozmontowaniu komponentu:

// ❌ BAD: Boilerplate-heavy, error-prone, no caching or deduplication
useEffect(() => {
  let isMounted = true;
  fetch('/api/todos')
    .then((res) => res.json())
    .then((data) => {
      if (isMounted) setTodos(data);
    });
  return () => { isMounted = false; };
}, []);

Mimo to istnieją braki. Nie ma obsługi błędów, stanu ładowania, usuwania duplikatów gdy dwa komponenty żądają tych samych danych, cache’owania pomiędzy ekranami ani możliwości ponownej próby. Flaga isMounted jedynie hamuje późną aktualizację; nie anuluje żądania, co zrobiłby AbortController.

Co napisać zamiast tego

Należy użyć biblioteki stworzonej do zarządzania stanem serwera, takiej jak TanStack Query, SWR lub RTK Query. Odpowiednia funkcja w TanStack Query deklaruje klucz cache’u oraz funkcję pobierania danych i zwraca dane, stan ładowania oraz informacje o błędach:

// ✅ GOOD: Declarative, automatically cached, auto-retried
const { data: todos, isLoading, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

Biblioteki te automatycznie zajmują się ponowną weryfikacją w tle, eliminacją duplikatów zapytań, próbami ponownych wysyłek oraz współdzieleniem cache’u, a także oferują ustrukturyzowane wsparcie dla optymistycznych aktualizacji oraz integrację z mechanizmami obsługi błędów. Kod komponentu skraca się do opisu tego, czego potrzebuje.

3. Zbiór wywołań useState, gdzie pasuje reducer

useState jest idealny do prostych wartości, takich jak przełącznik lub pole tekstowe. Gdy aktualizacje zależą od wcześniejszych wartości lub stan obejmuje nawiasowane tablice i obiekty zmieniane przez kilka różnych działań, użycie wielu funkcji ustawiania powoduje nadmiarowy rozmiar komponentów i sprzyja występowaniu błędów związanych ze starym stanem.

Jak to wygląda

Jeden komponent deklaruje od pięciu do dziesięciu hooków useState i aktualizuje je za pomocą wielu funkcji zwrotnych w celu dodawania, edytowania, przełączania i usuwania elementów. Zasady biznesowe w ten sposób rozproszone są między obsługami zdarzeń.

Co napisać zamiast tego

useReducer przenosi przejścia stanu poza komponent do jednej funkcji. Poniższy reducer używa zjednoczenia dyskryminowanego dla swoich działań, dzięki czemu TypeScript wie o typie danych w każdym przypadku. Dodawanie zadania polega na dołączeniu nowego obiektu z wygenerowanym id; przełączanie polega na przetworzeniu listy i zmianie stanu odpowiadającego elementu. Należy zauważyć, że w wyświetlonej linii kodu znajduje się błąd: ...todo.completed powinno być !todo.completed, co oznacza negację logiczną, w przeciwnym razie kod nie skompiluje się.

type Action =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: string };

function todoReducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: crypto.randomUUID(), text: action.payload, completed: false }];
    case 'TOGGLE_TODO':
      return state.map((todo) =>
        todo.id === action.payload ? { ...todo, completed: ...todo.completed } : todo
      );
    default:
      return state;
  }
}

Reduktorzy przynoszą korzyści na dwa sposoby:

  • Testowalność. Reduktor to funkcja czysta, więc można go przetestować bez renderowania niczego ani symulowania React.
  • Przewidywalność. Każda aktualizacja przechodzi przez nazwaną akcję zamiast arbitralnych funkcji ustawiających wywoływanych z rozproszonych callbacki, co ułatwia śledzenie i rejestrowanie zmian.

4. Brak testów, ponieważ „testowanie to duży projekt”

Wiele zespołów traktuje testowanie jako coś albo nic: albo pełna konfiguracja end-to-end, która wydaje się przytłaczająca, albo w ogóle nic. Lepsze podejście polega na rozpoczęciu od testów, które są najłatwiejsze do napisania i najbardziej przydatne do utrzymania.

Należy ustalać priorytety w następującej kolejności:

  • Czysta logika biznesowa. Narzędzia pomocnicze, formatery, walidatory i funkcje użyteczne znajdujące się poza drzewem komponentów.
  • Reduktorzy i niestandardowe hooki. Czysta logika przekształceń stanu.
  • Krytyczne ścieżki użytkownika. Kluczowe interakcje, testowane za pomocą React Testing Library (@testing-library/react).
  • Dobrym pierwszym krokiem jest test reduktora. Tworzy on stan początkowy, wysyła akcję bezpośrednio przez funkcję i sprawdza wynik, bez konieczności renderowania:

    // Example: Testing a reducer takes 2 minutes and provides huge peace of mind
    test('should toggle todo status', () => {
      const initialState = [{ id: '1', text: 'Write Article', completed: false }];
      const newState = todoReducer(initialState, { type: 'TOGGLE_TODO', payload: '1' });
    
      expect(newState[0].completed).toBe(true);
    });
    

    Taki test zajmuje kilka minut na napisanie i chroni zasady, od których zależy cała funkcjonalność. Gdy błąd pisowni w reduktorze z poprzedniego rozdziału zostanie naprawiony, to właśnie taki test by go wykrył.

    5. Używanie useEffect do synchronizacji React z samym sobą

    useEffect to najczęściej niewłaściwie używany hook w React. Częstym wzorcem jest jego wykorzystanie do kopiowania lub modyfikacji stanu wewnętrznego po zmianie propu, co powoduje dodatkowe renderowanie i krótkotrwałe niezgodności w interfejsie użytkownika.

    Często pojawiają się dwa błędne przekonania:

    • "Stan musi być aktualizowany w efekcie, gdy zmienia się właściwość." Zazwyczaj nie. Oblicz wartość podczas renderowania lub, jeśli stan potomka powinien zostać ponownie utworzony, podaj mu key, który zmienia się wraz z odpowiednią właściwością, aby React ponownie go załadował.
    • "Zachowanie kliknięcia powinno znajdować się w efekcie." Logika uruchamiana przez działanie użytkownika powinna znajdować się w obsłudze zdarzeń, takiej jak onClick, a nie w efekcie, który monitoruje zmianę stanu spowodowaną kliknięciem.

    Zasada przewodnia: efekty służą jako wyjście awaryjne do synchronizacji z systemami poza Reactem, takimi jak API przeglądarki, subskrypcje, narzędzia analityczne czy widgety DOM od dostawców zewnętrznych. Dane, które już posiada React, można przekształcać bez ich użycia. Aby zapoznać się z bardziej szczegółowym omówieniem wraz z przykładami przed i po, sprawdź jak przestać synchronizować stan z useEffect.

    6. Memorowanie wszystkiego domyślnie

    Działania związane z optymalizacją wydajności często oscylują między dwoma skrajnościami: całkowitym ignorowaniem kosztów renderowania lub otaczaniem każdego komponentu funkcją React.memo, a każdej funkcji – funkcją useCallback.

    Dlaczego powszechne memorowanie przynosi odwrotne efekty

    useMemo i useCallback nie są bezkosztowe. React musi przechowywać zależności i porównywać je przy każdym renderowaniu. W przypadku komponentu, który łatwo się renderuje, takie zarządzanie może kosztować tyle samo co sam proces renderowania, który próbuje się pominąć, a ponadto utrudnia czytanie kodu. Memoizacja jest przydatna tylko wtedy, gdy wszystkie właściwości są stabilne; jeden obiekt inline lub funkcja strzałkowa przekazana zmemorizowanemu potomkowi unieważnia jej efekt.

    Bardziej przemyślany podejście

    • Najpierw sprawdź profil. Narzędzie Profiler w React DevTools pokazuje, które komponenty faktycznie są renderowane zbyt często lub zbyt wolno.
    • Napraw strukturę przed dodaniem memoizacji. Przeniesienie stanu do komponentu, który go używa, lub przekazywanie kosztownych poddrzew jako children, często zapobiega niepotrzebnym renderowaniom bez konieczności stosowania memoizacji.
  • Memoizuj tam, gdzie to ma znaczenie. Zastosuj React.memo do dużych list i złożonych wykresów, a także utrzymuj ich właściwości stabilne za pomocą useCallback i useMemo.
  • 7. Traktowanie CSS i dostępności jak pracy kogoś innego

    Dobrzy inżynierowie React są również biegli w platformie przeglądarki. Komponenty są tak dobre, jak tylko pozwalają na to użyte przez nie tagi i style.

    Obszary, które warto opanować:

    • Nowoczesne układanie. Musisz dobrze znać Flexbox i CSS Grid, aby pozycjonowanie absolutne nie było już rozwiązaniem awaryjnym dla zwykłych układów.
    • Sztywne stylizowanie. Przechowuj style obok komponentów za pomocą CSS Modules, Tailwind CSS lub CSS-in-JS, zamiast polegać na dużych plikach stylów globalnych, w których nazwy klas się nakładają.
    • Dostępność i użyteczność. Używaj elementów semantycznych, takich jak prawdziwy <button> zamiast <div onClick={...}>. Upewnij się, że nawigacja klawiaturą za pomocą Tab, Enter i Space działa wszędzie. Dodaj widoczne style :focus-visible oraz wyraźne stany interaktywne, np. wskaźnik kursora na elementach klikalnych.

    Lista kontrolna przy przeglądaniu

    Przy sprawdzaniu pull requesta w React zwracaj uwagę na następujące zamienniki:

    • Wartości pochodne przechowywane w dodatkowych hookach useState: obliczaj je podczas renderowania.
    • Ręczne wywołania API wewnątrz useEffect: użyj TanStack Query, SWR lub RTK Query.
    • Liczne wywołania useState dla złożonych tablic i obiektów: przenieś logikę transformacji do useReducer i przetestuj ten reducer.
    • Brak testów, ponieważ konfiguracja wydaje się kosztowna: zacznij od funkcji czystych, reducerów i niestandardowych hooków.
    • useEffect służy do synchronizacji stanu: oblicz wartość lub obsłuż ją w obsłudze zdarzeń.
    • useCallback i React.memo wszędzie: sprawdź wydajność przed użyciem mechanizmów memoizacji.
    • Eлементy div nadające się do kliknięcia i brak stylów skupienia: używaj semantycznego HTML i sprawdzaj dostępność za pomocą klawiatury.

    Podsumowanie

    Większość tych problemów ma wspólną przyczynę: stan lub logika istniejące w zbyt wielu miejscach lub uruchamiane częściej, niż to konieczne. Zachowanie jednego źródła prawdy, pozwalanie specjalnie stworzonym narzędziom zarządzać danymi serwera, izolowanie transformacji w funkcjach czystych oraz przechowywanie efektów wyłącznie dla świata zewnętrznego – to wszystko zmniejsza liczbę elementów, które mogą powodować problemy. Kod z mniejszą liczbą takich elementów jest łatwiejszy do testowania, łatwiejszy do optymalizacji w kluczowych momentach i budzi większe zaufanie u kolejnych recenzentów.