Strona główna / Artykuły

Utrzymaj haczyki bez głowy: oddzielenie zachowania alertów od ich wyświetlania

Dowiedz się, dlaczego hook w React, który zwraca JSX, ukrywa część drzewa komponentów i łączy zachowanie z prezentacją, oraz jak przeprojektować go tak, aby zamiast tego ujawniał stan.

Wydawca: REACTAPP.TOP5 min czytania1092 słów

Źródła: Why I’m Suspicious of Hooks That Return JSX

Hooki niestandardowe doskonale sprawdzają się w ukrywaniu szczegółów implementacji, ale czasami ukrywają zbyt wiele. Hook, który zwraca gotowy JSX, wydaje się wygodny w miejscu wywołania, ale przenosi część drzewa interfejsu użytkownika za API, które wygląda jak zwykłe dane, oraz łączy zachowanie z jedną konkretną prezentacją. Korzystając z typowego hooka do alertów z edytora dashboardów, ten artykuł pokazuje, jak rozpoznać takie podejście, jak przepisać kod na hook bezinterfejsowy oraz co można zyskać dzięki kilku dodatkowym liniom kodu.

Hook do alertów, który renderuje własny interfejs

Alerty doskonale pasują do hooków – wymagają stanu, ewentualnie timerów, mechanizmów zamknięcia oraz systemu kolejek. Oto jak taki hook jest wykorzystywany:

const {
  showSuccess,
  showError,
  alerts,
} = useAlerts();

return (
  <>
    {alerts}

    <Dashboard />
  </>
);

Już teraz widać oznaki, że coś jest nie tak. Funkcja alerts jest bezpośrednio wstawiana do JSX, co silnie sugeruje, że hook zwraca elementy renderowane. Hook jest odpowiedzialny za zachowanie, ponieważ dostarcza funkcje showSuccess i showError, a najwyraźniej także za wygląd alertów.

Implementacja to potwierdza:

const useAlerts = () => {
  const [alerts, setAlerts] = useState<Alert[]>([]);

  const showSuccess = (message: string) => {
    // update state
  };

  const showError = (message: string) => {
    // update state
  };

  ...

  return {
    showSuccess,
    showError,
    alerts: (
      <AlertContainer>
        {alerts.map(({ id, type, message }) => (
          <Alert
            key={id}
            type={type}
            message={message}
          />
        ))}
      </AlertContainer>
    ),
  };
};

useAlerts kontroluje stan alertów, funkcje zmieniające ten stan oraz strukturę kodu: obudowę AlertContainer oraz po jedno elementy Alert na każdą pozycję. Granica pomiędzy logiką a prezentacją zniknęła.

Dlaczego zwracany JSX jest mylący

Wygląda jak dane

Z perspektywy użytkownika nic nie wskazuje na to, że alerts jest poddrzewem React. Znajduje się obok dwóch funkcji, ma nazwę w liczbie mnogiej, która sugeruje tablicę rekordów, i można go łatwo pomylić ze stanem:

const {
  showSuccess,
  showError,
  alerts,
} = useAlerts();

Jedynym sposobem na poznanie prawdy jest otwarcie tego hooka.

Ukrywa źródło interfejsu

Drzewo komponentów jest cenne po części dlatego, że dokumentuje, skąd pochodzi każda część interfejsu. Przy przeglądaniu panelu sterowania i widzeniu tego:

{alerts}

<Dashboard />

nie można stwierdzić, co renderuje alerts, jakie komponenty montuje, od jakiego kontekstu lub atrybutów zależą te komponenty ani jak są stylizowane, bez zagłębiania się w ten hook. Część drzewa została przeniesiona za coś, co wygląda na element behawioralny.

Łączy dwie niespowiązane decyzje

Hook nie może już wykazywać zachowania powiadamiania bez jednoczesnego określenia komponentu powiadomienia. Każdy ekran, który potrzebuje logiki powiadamiania, dziedziczy jego wygląd, niezależnie od tego, czy pasuje mu on, czy nie. Nie ma żadnego technicznego powodu, dla którego te dwa aspekty musiałyby iść w parze.

Refaktoryzacja na hook bez interfejsu graficznego

Hook powinien zarządzać stanem oraz operacjami na nim wykonywanymi, i niczym więcej. Należy udostępnić surowy list powiadomień wraz z akcjami, w tym sposób na zamknięcie powiadomienia:

const {
  alerts,
  showSuccess,
  showError,
  dismissAlert,
} = useAlerts();

Następnie należy wyświetlić powiadomienia w komponencie, do którego to należy:

<AlertContainer>
  {alerts.map(({ id, type, message }) => (
    <Alert
      key={id}
      type={type}
      message={message}
      onDismiss={() => dismissAlert(id)}
    />
  ))}
</AlertContainer>

<Dashboard />

Teraz drzewo komponentów jest ponownie jasne. Każdy, kto je czyta, widzi AlertContainer, jeden element Alert na każdą pozycję oznaczoną kluczem id, oraz funkcję do zamknięcia powiadomienia połączoną z hookiem. Rola hooka jest jednoznaczna: dostarcza stan i akcje.

Czy dodatkowy kod stanowi problem?

Część odpowiedzialna za przetwarzanie danych rzeczywiście staje się dłuższa, i to nie jest problem. Kusi nas chęć dopracowania API do tego stopnia, by użytkownik prawie nic nie musiał pisać, ale takie dopracowanie może usunąć przydatne informacje architektoniczne. Pięć linijek kodu o oczywistej strukturze jest łatwiejsze do utrzymania niż jeden {alerts} ukrywający całą część interfejsu użytkownika.

Jeśli kilka ekranów wyświetla powiadomienia w identyczny sposób, nadal można uniknąć powtórzeń bez konieczności łączenia interfejsu z funkcją hook: można stworzyć komponent AlertList, który przyjmuje jako parametry alerts i onDismiss, albo taki, który sam wywołuje funkcję hook. W obu przypadkach renderowanie odbywa się w komponencie, który jest widoczny w drzewie komponentów i może być analizowany pod kątem wydajności, memoizowany oraz testowany jak każdy inny komponent.

Możliwe staje się ponowne użycie

Z wbudowanym JSX, ponowne wykorzystanie danej logiki zmusza do ponownego użycia również prezentacji, co jest założeniem, które nie zawsze się sprawdza. W wersji bez głowy aplikacji obszar powiadomień, baner wewnątrz tekstu oraz układ mobilny mogą dzielić dokładnie ten sam cykl życia powiadomień, renderując je jednak w różny sposób. Komponent powiadomień może również ewoluować samodzielnie, bez ingerencji w hook zarządzający czasem wyświetlania i usuwania go.

Związaną pułapkę: hooki zwracające komponenty

Bliźnim odpowiednikiem tego problemu jest hook, który definiuje i zwraca funkcję komponentu, na przykład const AlertView = () => ... wewnątrz ciała hooka. Ponieważ przy każdym renderowaniu tworzy się nowa funkcja, React za każdym razem widzi nowy typ komponentu i demontuje oraz ponownie montuje odpowiedni poddrzewo, usuwając jego stan wewnętrzny oraz DOM. Zwracanie elementów, jak to robi oryginalny hook, zapobiega temu konkretnemu błędowi, ale powyższy problem projektowy pozostaje.

Główne wnioski

  • Użycie JSX w ramach hooka zazwyczaj oznacza połączenie dwóch obowiązków w jedną abstrakcję.
  • Wartość zwracana, która wygląda jak dane, ale w rzeczywistości stanowi interfejs użytkownika, ukrywa przed czytelnikami część drzewa komponentów.
  • Zachowuj hooki bez głowy: zwracaj stan i akcje, a renderowanie realizuj w komponentach.
  • Dla wspólnej prezentacji lepiej wyodrębnić komponent zamiast zwracać markup z hooka.
  • Przy analizie takiego hooka zadaj sobie jedno pytanie: jaki jest powód, dla którego ten hook musi decydować o wyglądzie interfejsu użytkownika?

Pozycje pokrewne

  • Komponenty wyższego rzędu: od opakowań klasowych do funkcji i hooków — Dowiedz się, jak działają komponenty wyższego rzędu w React, jak ten sam HOC w formie klasy i funkcji wygląda, oraz dlaczego niestandardowe hooki zastąpiły większość zastosowań HOC.
  • Ograniczone prompty: jak zapobiec tworzeniu równoległych stacków przez asystentów AI — Dlaczego niejasne prośby o funkcje sprawiają, że asystenci programistyczni dodają duplikatowe biblioteki i opakowania, oraz jak ograniczone prompty z ścieżkami, niezmiennikami i wyraźnymi ograniczeniami to rozwiązują.