Home

Dlaczego nie używam komponentu Link z React Router?

Komponent <Link> w React Router (i innych routerach) to popularny, deklaratywny sposób na nawigację z poziomu widoku. Moim zdaniem to bad pattern.

Komponent <Link to="" /> zdaje się być znany każdemu, kto chociaż raz użył React Router (i chyba każdego innego routera?). Używa się go zamiast natywnego <a />, aby przechwycić routing i operować na nim po stronie klienta, np. używając History API (React Router 4 używa swojej rozszerzonej implementacji).

Alternatywnym sposobem na routing jest skorzystanie z interfejsu history, standardowo – history.push('/some-url').

Przyjęło się, że komponent <Link /> jest domyślnym zastosowaniem, jako że używa deklaratywnego API, które jest właśnie największą siłą Reacta. Renderuje on pod spodem element <a/>, aby zachować semantykę i spina się z React Routerem. Jeśli jednak musieliśmy modyfikować URL w sposób imperatywny (np przekierować gdy zapytanie do serwera będzie udane), standardowo używaliśmy wstrzykniętego do naszego widoku propa history.

Ten, podobnie jak np match czy location trafia do naszego widoku np. poprzez HOC withRouter, który wyciąga je z kontekstu i wstrzykuje do widoku.

Czym w ogóle jest <Link />?

Jeśli zerkniemy do kodu źródłowego, od razu zauważymy, że komponent <Link /> to nic innego jak owrapowany element <a/>, który wyciąga z kontekstu obiekt history i wykonuje na nim metodę .push(), do której przekazuje prop to=.

Czyli można śmiało stwierdzić, że jest to tylko deklaratywna owijka na operacjach na history – na tym zresztą opiera się cała masa Reactowych komponentów – o fajnie.

Zwróć jednak uwagę na to, że Link bezpośrednio woła do kontekstu, a więc potrzebuje być wewnątrz <BrowserRouter> </BrowserRouter (lub innego <Router />a). Na pewno zdażyło Ci się podczas testowania dostać błąd, że użyto <Link /> poza kontekstem routera, podobnie jak <NavLink /> czy <Route />.

I tutaj dochodzę do sedna – dlaczego staram się nie używać deklaratywnego Link componentu.

Link to tight coupling widoku i routera

Cała architektura Reacta i jego dobre praktyki opierają się na maksymalnym użyciu presentational components, które idealnie otrzymują minimalną możliwą ilość propsów i skupiają się na jednej rzeczy – wyświetlaniu. Kierujemy się tutaj przede wszystkim Single Responsibility Principle, które moim zdaniem jest najważniejszą zasadą w programowaniu.

Sztandarowy przykład użycia w aplikacji linków, to nawigacja. Napiszmy sobie prezentacyjny komponent nawigacji – na razie bez routingu:

interface ILink extends HTMLAttributes<HTMLAnchorElement> {}

interface INavigationProps {
  links: ILink[];
}

const Navigation = ({ links }: INavigationProps) => (
  <nav>
    {links.map(link => (
      <a {...link} />
    ))}
  </nav>
);

Nasz komponent przyjmuje listę linków, które dziedziczą wszystkie atrybuty po elemencie <a />. Mniej więcej ma on jedną odpowiedzialność, chociaż tak naprawdę fakt działania elementu anchor sprawia że jest on niedeterministyczny.

Aby spiąć go z React Routerem, wystarczyłoby zamienić to:

<a {...link} />

na

<Link to={link.href} {...link} />

Jednak dalej pozostaje nam ten sam problem – mimo że chcieliśmy prosty komponent prezentacyjny, to de facto kliknięcie w link powoduje nieprzewidziane efekty – zależne od globalnego kontekstu, konfiguracji routingu itd, kliknięcie może powodować zmianę URLa, błąd czy 404.

Dodatkowo stworzyliśmy sobie tight coupling. Nawigacja ta jest w ogóle nieprzenośna i nie działa poza konkretną implementacją rutera. Co więcej, przestaje być testowalna – aby móc to przetestować, musimy w testach zamockować Router oraz konfiguracje routów, następnie sprawdzać czy kliknięcia faktycznie odzwierciedlają URL – co nie do końca jest już unit testem (co nie musi być też złe).

Rozdzielmy abstrakcje

W tej sytuacji preferuję rozdzielić warstwę prezentacji oraz warstwę obsługi routingu.

W warstwie prezentacji dalej przekażemy potrzebne linki, jednak przekażemy jedynie eventy, że linki zostały kliknięte.

Zmodyfikowany przykład będzie więc wyglądać mniej więcej tak:

interface ILink extends HTMLAttributes<HTMLAnchorElement> {}

interface INavigationProps {
  links: ILink[];
  onNavigate: (to: string) => any;
}

export const Navigation = ({ links, onNavigate }: INavigationProps) => {
  const onClick = (e: MouseEvent<HTMLAnchorElement>, path: string) => {
    e.preventDefault();

    onNavigate(path);
  };

  return (
    <nav>
      {links.map(link => (
        <a {...link} onClick={e => onClick(e, link.href)} />
      ))}
    </nav>
  );
};

Jak widać, dodałem uniwersalny handler onNavigate, a same eventy klikania blokuję przez event.preventDefault(). W ten sposób nawigacja nie działa, ale nie ma też żadnych zewnętrznych zależności. Nie ma problemu ani z testowaniem, ani z przenośnością.

Teraz skupmy się na warstwie, która obsłuży logikę nawigacji.


export const withNavRouting = (
    Component: React.ComponentType<INavigationProps & RouteComponentProps>
) => ({ onNavigate, history, ...props }: INavigationProps) => {
    const handleNavigate = (path: string) => {
        history.push(path);
    };

    return <Component onNavigate={handleNavigate} {...props} />;
};

export const NavigationWithRouter = compose(
    withRouter, // HOC z react-router-dom
    withNavRouting
)(Navigation);

Jak widać, robimy tutaj parę operacji:

  1. Tworzymy nowy HOC, który przyjmuje te same propsy co komponent Navigation i rozszerza je o RouteComponentProps – więc ten HOC będzie musiał być użyty później niż withRouter, który te propsy dostarczy.
  2. Następnie wyciągamy z propsów callback onNavigate oraz history i zostawiamy resztę ...props.
  3. Renderujemy <Component>, któremu przekazujemy onNavigate={handleNavigate}, które zdefiniowaliśmy w tej funkcji oraz przekazujemy resztę propsów dalej.
  4. Nasz handleNavigate dostaje z komponentu nawigacji ścieżkę, którą ma element <a> w atrybucie <href>.
  5. Na końcu komponujemy dwa HOC ze sobą – withRouter, który wyciąga z kontekstu aplikacji między innymi history oraz nasz HOC withNavRouting, a następnie owijamy nimi nasz komponent.

Mamy teraz jawny podział trzech abstrakcji:

  1. Navigation – czysty komponent odpowiedzialny tylko za widok
  2. withNavRouting – HOC, który odpowiada za obsługę eventów z widoku i łączeniu ich z routerem
  3. NavigationWithRouterskomponowany komponent widoku i logiki, który działa sobie niezależnie.

Oczywiście na samym końcu umieszczamy naszą nawigację w widoku, wstrzykując jej tablicę linków.

Czemu tak?

No właśnie, po co tyle zabawy?

Testowalność

Każdy z tych elementów łatwo przetestować. Widok nie wymaga żadnych zewnętrznych dependencji (Rutera, kontekstu). HOC-owi można wstrzyknąć zamockowane history i tylko sprawdzać czy jest prawidłowo wywoływana, za to w pełni skomponowany komponent można testować w realny, życiowy sposób poprzez testy integracyjne.

Kompozycje

Wszystkie te warstwy to po prostu funkcje, które można ze sobą komponować. Przykładowo, nawigacja może zachowywać się nieco inaczej gdy użytkownik jest zalogowany, więc wystarczy dorzucić kolejny HOC lub podmienić withNavRouting na jakiś inny, który zmieni zachowanie. Finalnie, skomponowane komponenty mogą funkcjonować obok siebie, bez modyfikowania ich i przede wszystkich – bez modyfikacji kodu już obecnych.

Single responsibility principle

Kierowanie się zasadą SRP pozwala pisać łatwo utrzymywalny kod, który przede wszystkim jest zrozumiały – każdy „kawałek” odpowiada za jedną rzecz, minimalizując wysiłek poznawczy, szczególnie czytając czyjś kod.

Idąc tym tokiem, możemy na przykład dodać kolejne elementy, odpowiedzialne za różne elementy logiki.

Loose coupling

Nie wiem jak to się nazywa po polsku, ale loose coupling jest de facto efektem poprzednich punktów. I jasne, ciężko bronić tezy, że możemy sobie wymienić router (tego typu argumenty są szlachetne, ale rzadko realne), jednak możliwość oddzielenia kodu aplikacji od kodu dependencji osobną warstwą zawsze jest wartościowa.

Zasady te można w pełni zaadoptować w innych przypadkach, nie tylko routera, nie tylko w React – to po prostu dobre praktyki związane z programowaniem 🙂 Jednak na <Link /> skupiłem się w szczególności, bo uważam że jest to popularny pattern z dokumentacji, a zarazem może być szkodliwy.

Jeżeli nasz kod jest mało testowalny, to postawienie testów wymaga dużo czasu i cierpienia – a wtedy wiele osób dochodzi do wniosku, że po prostu nie ma kiedy pisać testów, hurr durr. Dalej już nie muszę chyba mówić.

Zgadzasz się lub wręcz przeciwnie? Możesz mi nawtykać na maila.