Moduł 5 - Zajęcia 9 - Nawigacja

Routing

Elementem wyróżniającym aplikację webową, w porównaniu do desktopowej, jest obecność URL. Zmieniając go, użytkownik może wyświetlać kolejne części aplikacji. Użytkownik może zapisać dany adres w postaci zakładki lub przesłać go do innego użytkownika, który dzięki temu zobaczy ten sam interfejs (z wyjątkiem danych prywatnych).

Routing - struktura nawigacji

Ścieżka / Route - pojedynczy element nawigacyjny

Routing nie jest "miłym dodatkiem" do aplikacji. Przeciwnie, strukturę nawigacji i zestaw podstron należy przemyśleć na samym początku.

Struktura łańcucha URL

Analogią łańcucha URL może być adres, pod którym mieszkasz: ulica, dom, mieszkanie. Dla każdego stanu interfejsu (tego co widzi użytkownik) powinien istnieć adres URL.

Przeanalizujmy z jakich części może składać się przykładowy URL.

  • https:// - protokół
  • mysite.com/ - host
  • books/e3q76gm9lzk - ścieżka, to gdzie znajdujemy się w aplikacji
  • e3q76gm9lzk - parametr url. Parametry bywają dynamiczne lub statyczne
  • ? - symbol początku łańcucha zapytania (search query)
  • ?category=adventure&status=unread - łańcuch zapytania (search query)
  • category=adventure - para parametr=wartość
  • & - symbol "I", rozdziela parametry łańcucha zapytania
  • #comments - kotwica (hash), określa położenie na stronie

Historia nawigacji

Historia nawigacji (przeglądania) zawiera informacje o historii naszego przeglądania danej zakładki przeglądarki. Wykorzystując właściwości i metody HTML5 History API możemy przechodzić do tyłu i do przodu po historii użytkownika i manipulować jego zawartością.

Jeśli chcesz lepiej zrozumieć React Router, po zapoznaniu się z podstawowymi koncepcjami rekomendujemy wrócić i przeanalizować artykuł A Little Bit of History.

Routing w React

W React nie ma wbudowanego modułu routingu, dlatego najczęściej wykorzystuje się React Router. Analogicznie jak React, dostarcza nam zestawu prymitywów do tworzenia interfejsu użytkownika. Zawiera również zestaw hooków do tworzenia routingu, zarządzania historią nawigacji użytkownika i wyświetlania różnych komponentów w zależności od obecnej wartości URL w łańcuchu adresowym przeglądarki.

            npm install react-router-dom
          

Komponent BrowserRouter

BrowserRouter - centrum sterowania routingiem, które kryje w sobie całą logikę współpracy z historią przeglądarki. Tworzy router i obiekt historii nawigacji, aby synchronizować interfejs z adresem URL. Wykorzystując kontekst React, przekazuje informację o bieżącym stanie historii nawigacji wszystkim potomkom. Na początku wystarczy owinąć komponentem BrowserRouter całą aplikację.

Komponent BrowserRouter to Router, który używa interfejsu API historii HTML5 (pushState, replaceState i zdarzenie popstate), aby zapewnić synchronizację interfejsu użytkownika z adresem URL. Dlatego też korzystamy już z rozbudowanego komponentu BrowserRouter.

            src/index.js
            import { BrowserRouter } from "react-router-dom";

            ReactDOM.createRoot(document.getElementById("root")).render(
              <React.StrictMode>
                <BrowserRouter>
                  <App />
                </BrowserRouter>
              </React.StrictMode>
            );
          

W kolejnej części modułu przeanalizujemy jak opisywać routing aplikacji.

Komponent BrowserRouter możemy także zapisać używając nowego podejścia, z wykorzystaniem hooków i RouterProvider , natomiast jest to rozwiązanie jeszcze mało popularne.

            import {
              createBrowserRouter,
              createRoutesFromElements,
              Route,
              RouterProvider,
            } from "react-router-dom";

            const router = createBrowserRouter(
              createRoutesFromElements(
                <Route path="/" element={<Root />}>
                  <Route path="dashboard" element={<Dashboard />} />
                  {/* ... etc. */}
                </Route>
              )
            );

            ReactDOM.createRoot(document.getElementById("root")).render(
              <React.StrictMode>
                <RouterProvider router={router} />
              </React.StrictMode>
            );
          

Jaki Router wybrać?

Komponenty Route i Routes

Komponent Route pozwala powiązać określony URL z konkretnym komponentem. Przykładowo, jeśli chcemy wyświetlić komponent About, kiedy użytkownik przechodzi na ścieżkę /about, należy opisać taką ścieżkę następująco:

            <Route path="/about" element={<About />} />
          

Wartością propsu element może być dowolny, poprawny JSX, ale w praktyce wykorzystywane są zawsze komponenty.

Komponent Route zawsze musi coś wyrenderować: komponent wskazany w propsie element jeśli path pokrywa się z bieżącą wartością segmentu pathname w polu adresowym przeglądarki lub null, jeśli się nie pokrywa.

Możemy zdefinować dowolną ilość ścieżek, ale minimum to jedna na każdą stronę aplikacji. Przypuśćmy, że tworzymy aplikację sklepu z odzieżą:

            src/components/App.jsx

            import { Routes, Route } from "react-router-dom";
            import Home from "path/to/pages/Home";
            import About from "path/to/pages/About";
            import Products from "path/to/pages/Products";

            export const App = () => {
              return (
                <div>
                  <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/about" element={<About />} />
                    <Route path="/products" element={<Products />} />
                  </Routes>
                </div>
              );
            };
          

Grupę ścieżek musimy owinąć w komponent Routes, nawet jeśli ścieżka jest tylko jedna. Route nigdy nie może być wykorzystywany poza Routes. Komponent Routes odpowiada za logikę wyboru najlepiej pasującego Route dla bieżącej wartości URL w polu adresowym przeglądarki.

Wiesz już, że jedną z konwencji struktury plików w aplikacji jest zapisywanie wszystkich komponentów w folderze src/components. Komponent strony to również zwykły komponent React, ale dla wygody i ustrukturyzowania, takie komponenty przechowujemy oddzielnie w folderze src/pages

Strona błędu nawigacji

Biorąc pod uwagę dotychczasowy opis routingu sklepu internetowego, kiedy użytkownik przejdzie po odnośniku na adres URL /non-existing-route (lub na dowolny inny, który nie istnieje w naszej aplikacji) - zobaczy pustą zakładkę przeglądarki bez żadnej zwartości. Dzieje się tak, gdyż żaden z opisanych przez nas Route nie pasuje do tego URL. Powinniśmy informować użytkownika o tym, że adres który wybrał nie istnieje (nie został znaleziony). W tym celu na samym końcu listy ścieżek dodamy jeszcze jeden Route, który będzie pokrywał się z dowolnym URL, ale zostanie wybrany tylko wtedy, gdy żadna inna ścieżka nie będzie pasować.

            src/components/App.jsx

            import { Routes, Route } from "react-router-dom";
            import Home from "path/to/pages/Home";
            import About from "path/to/pages/About";
            import Products from "path/to/pages/Products";
            import NotFound from "path/to/pages/NotFound";

            const App = () => {
              return (
                <div>
                  <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/about" element={<About />} />
                    <Route path="/products" element={<Products />} />
                    <Route path="*" element={<NotFound />} />
                  </Routes>
                </div>
              );
            };
          

Symbol * w propsie path wskazuje na to, że ścieżka ta może pokrywać się z dowolną wartością URL. Jeśli żaden wcześniejszy Route nie będzie pasował, ostatni wyświetli użytkownikowi stronę z wiadomością, że pod danym adresem nie znajduje się żadna część aplikacji.

Komponenty Link i NavLink

Teraz przeanalizujemy jak tworzyć odnośniki (linki) do różnych stron naszej aplikacji. W celu utworzenia nawigacji w aplikacji Reactowej nie możemy korzystać ze zwykłego tagu <a href="/about">. Po kliknięciu w taki odnośnik przeglądarka przeładuje stronę, zamiast zmienić URL na obecnej stronie i pozwolić routerowi wykonać nawigację u klienta.

Do tworzenia odnośników wykorzystuje się komponenty Link i NavLink. Renderują one tag <a>, ale standardowe zachowanie odnośnika jest zmienione tak, aby po kliknięciu URL aktualizował się bez przeładowania strony.

              <nav>
                <Link to="/">Home</Link>
                <Link to="/about">About</Link>
                <Link to="/products">Products</Link>
              </nav>
          

Komponent NavLink różni się tylko tym, że dostaje klasę .active kiedy bieżący URL pokrywa się z wartością propsa to. Można to wykorzysta do zmiany jego stylów.

            src/components/App.jsx

            import { Routes, Route, NavLink } from "react-router-dom";
            import styled from "styled-components";
            import Home from "path/to/pages/Home";
            import About from "path/to/pages/About";
            import Products from "path/to/pages/Products";

            const StyledLink = styled(NavLink)`
              color: black;

              &.active {
                color: orange;
              }
            `;

            export const App = () => {
              return (
                <div>
                  <nav>
                    <StyledLink to="/" end>
                      Home
                    </StyledLink>
                    <StyledLink to="/about">About</StyledLink>
                    <StyledLink to="/products">Products</StyledLink>
                  </nav>

                  <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/about" element={<About />} />
                    <Route path="/products" element={<Products />} />
                  </Routes>
                </div>
              );
            };
          

Parametry URL

Dynamiczne parametry URL są podobne do parametrów funkcji - zawsze mają jedną nazwę, ale mogą mieć różne wartości. Pozwalają zdefiniować szablon adresu URL, którego części mogą mieć dynamiczną wartość. Na przykład, nie ma sensu określanie oddzielnej ścieżki dla każdego posta na blogu - mogą ich być przecież tysiące. Jeśli chodzi o strukturę, poszczególne strony postów będą identyczne. Różnić się będzie nazwa, obrazek, autor, tekst itp. Dlatego zamiast określać dokładną ścieżkę dla każdego artykułu, możemy zadeklarować jedną - z dynamicznym parametrem. Na jego podstawie będziemy określać, jaki post należy wyświetlić w danym momencie. Dynamiczne parametry URL zapisujemy poprzedając nazwę parametru dwukropkiem (:).

            <Route path="/blog/:postId" element={<BlogPost />} />
          

Za każdym razem, gdy użytkownik będzie odwiedzać adres odpowiadający dynamicznej ścieżki /blog/:postId (np. /blog/react-fundamentals lub /blog/top-5-css-tricks), będzie mu się wyświetlała odpowiednia strona postu.

Możemy nazywać parametry URL dowolnie, jednak warto zadbać o odpowiednie oddanie jego znaczenia.

Dodajmy do naszej aplikacji ścieżkę strony jednego produktu - /products/:productId. Jest to oddzielna strona, w żaden sposób niezwiązana z /products - stroną wyświetlania wszystkich produktów.

            src/components/App.jsx

            import { Routes, Route, Link } from "react-router-dom";
            import Home from "path/to/pages/Home";
            import About from "path/to/pages/About";
            import Products from "path/to/pages/Products";
            import NotFound from "path/to/pages/NotFound";
            import ProductDetails from "path/to/pages/ProductDetails";

            export const App = () => {
              return (
                <div>
                  <nav>
                    <Link to="/">Home</Link>
                    <Link to="/about">About</Link>
                    <Link to="/products">Products</Link>
                  </nav>
                  <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/about" element={<About />} />
                    <Route path="/products" element={<Products />} />
                    <Route path="/products/:productId" element={<ProductDetails />} />
                    <Route path="*" element={<NotFound />} />
                  </Routes>
                </div>
              );
            };
          

Wartość parametru URL powinna być unikalna wewnątrz kolekcji, dlatego najczęściej wykorzystywane są identyfikatory/klucze obiektów (id), które ustanawia baza danych (liczby lub łańcuchy). Z tego względu adres najczęściej ma postać jak /products/1, /proudcts/2 itd.

Hook useParams

Zwraca obiekt ze wszystkimi dynamicznymi parametrami, które istnieją w aktualnym adresie URL, ale tylko te które zostały zdefiniowane w ramach Routes. Na przykład, jeśli zadeklarowana została następująca ścieżka /books/:genreId/:authorName, i użytkownik znajduje się pod adresem /books/adventure/herman-melville, hook zwróci obiekt klucz-wartość postaci: genreId: adventure authorName: herman-melville.

            const { genreId, authorName } = useParams();
            console.log(genreId, authorName);// adventure, herman-melville
          

W celu otrzymania wartości dynamicznego paramteru URL, dla strony szczegółów produktu, wykorzystujemy hook useParams w komponencie strony produktu.

            src/pages/ProductDetails.jsx

            import { useParams } from "react-router-dom";

            const ProductDetails = () => {
              const { productId } = useParams();
              return <div>Now showing product with id - {productId}</div>;
            };
          

Mając wartość parametru możemy wykonać zapytanie do API i otrzymać pełną informację o produkcie, zgodnie z jego identyfikatorem, po czym wyrenderować jego stronę.

Zagnieżdżone ścieżki

Zagnieżdżone ścieżki pozwalają opisywać logikę "strony w stronie" (sytuacja kiedy dla jednego adresu URL, oprócz komponentu-rodzica całej strony, będzie wyświetlać się jeszcze zagnieżdżony komponent-dziecko).

Na przykład, chcielibyśmy aby na każdej ze stron /about/mission, /about/team i /about/reviews wyświetlał się komponent <About /> oraz dodatkowa, bardziej szczegółowa informacja w zależności od URL (artykuł o misji naszej firmy - 'mission', galeria z informacjami o pracownikach - 'team' i recenzje użytkowników ' reviews').

            // ❌ Nieprawidłowo
            <Route path="/about" element={<About />} />
            <Route path="/about/mission" element={<Mission />} />
            <Route path="/about/team" element={<Team />} />
            <Route path="/about/reviews" element={<Reviews />} />
          

Jeżeli przygotujemy Routing w ten sposób, to otrzymamy cztery niezależne strony. Na /about będzie wyświetlać się tylko strona z informacjami, a na about/team jedynie galeria pracowników.

Wykorzystajmy składnię deklarowania zagnieżdżonej ścieżki, której komponent będzie wyświetlać się wewnątrz strony-rodzica.

            // ✅ Prawidłowo
            <Route path="/about" element={<About />}>
              <Route path="mission" element={<Mission />} />
              <Route path="team" element={<Team />} />
              <Route path="reviews" element={<Reviews />} />
            </Route>
          

Zwróć uwagę na kilka szczegółów:

  • Deklaratywnie umieściliśmy ścieżki-dzieci wewnątrz rodzica Route. Właśnie taka składnia wskazuje na ścieżkę zagnieżdżoną, której komponent będzie wyświetlał się gdzieś wewnątrz komponentu rodzica.
  • Wartość propsa path w zagnieżdżonej ścieżce definiuje się w relacji/odniesieniu do rodzica. Właśnie dlatego przekazaliśmy wartość path="mission", a nie pełną ścieżkę path="/about/mission"
  • Ścieżki relatywne zapisuje się bez poprzedzającego symbolu /, to znaczy path="mission", a nie path="/mission". Jeżeli dodalibyśmy slash, to utworzylibyśmy oddzielną ścieżkę /mission i zepsulibyśmy logikę Routingu.

Pełna konfiguracja Routingu naszej aplikacji będzie wyglądała tak.

            src/components/App.jsx

            import { Routes, Route, Link } from "react-router-dom";
            import Home from "path/to/pages/Home";
            import About from "path/to/pages/About";
            import Products from "path/to/pages/Products";
            import NotFound from "path/to/pages/NotFound";
            import ProductDetails from "path/to/pages/ProductDetails";
            import Mission from "path/to/components/Mission";
            import Team from "path/to/components/Team";
            import Reviews from "path/to/components/Reviews";

            export const App = () => {
              return (
                <div>
                  <nav>
                    <Link to="/">Home</Link>
                    <Link to="/about">About</Link>
                    <Link to="/products">Products</Link>
                  </nav>
                  <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/about" element={<About />}>
                      <Route path="mission" element={<Mission />} />
                      <Route path="team" element={<Team />} />
                      <Route path="reviews" element={<Reviews />} />
                    </Route>
                    <Route path="/products" element={<Products />} />
                    <Route path="/products/:productId" element={<ProductDetails />} />
                    <Route path="*" element={<NotFound />} />
                  </Routes>
                </div>
              );
            };
          

Ostatnią kwestią jest wskazanie, w którym miejscu, w komponencie Route-rodzica About, chcemy renderować zagnieżdzone Route-dzieci. W tym celu wykorzystywany jest komponent Outlet.

            src/pages/About.jsx

            import { Link, Outlet } from "react-router-dom";

            export const About = () => {
              return (
                <div>
                  <h1>About page</h1>
                  <ul>
                    <li>
                      <Link to="mission">Read about our mission</Link>
                    </li>
                    <li>
                      <Link to="team">Get to know the team</Link>
                    </li>
                    <li>
                      <Link to="reviews">Go through the reviews</Link>
                    </li>
                  </ul>
                  <Outlet />
                </div>
              );
            };
          

Jeśli URL pokryje się z wartością propsa path zagnieżdżonej ścieżki, Outlet wyrenderuje jego komponent. Natomiast dla ścieżki/about Outlet zwróci null, co nie wpłynie negatywnie na układ komponentu-rodzica.

Zwróć uwagę na wartość propsa to komponentu Link w przykładzie powyżej. Tak jak path zagnieżdżonej ścieżki, wartość propsa to zagnieżdżonych odnośników także deklaruje się w odniesieniu do adresu URL Route-rodzica. Komponent About renderuje się na adresie /about, dlatego odnośnik z to="mission" będzie prowadził do /about/mission. Jeżeli natomiast byłaby potrzeba utworzenia odnośnika do innej strony, wtedy konieczne będzie wskazanie pełnej ścieżki,np. to="/products".

Ścieżki indeksowe

Koncepcja zagnieżdżonych ścieżek pozwala nam również na tworzenie komponentów ze wspólną, powtarzającą się na wielu stronach zawartością. Idealnym przykładem jest tutaj menu nawigacji. Z reguły powinno się ono znajdować na każdej stronie, aby umożliwić swobodną nawigację po całej aplikacji. Dublowanie kodu i dodawanie komponentu nawigacji do każdego komponentu strony nie jest zbyt praktyczne. Wykorzystajmy więc poznaną dotychczas składnię, aby to uprościć.

Na początek zdefiniujmy w naszej aplikacji nagłówek z logotypem i główną nawigacją, a także kontener ograniczający szerokość zawartości każdej strony.

            src/components/App.jsx

            // Imports

            export const App = () => {
              return (
                <Container>
                  <Header>
                    <Logo>
                      <span role="img" aria-label="computer icon">
                        💻
                      </span>{" "}
                      GoMerch Store
                    </Logo>
                    <nav>
                      <Link to="/">Home</Link>
                      <Link to="/about">About</Link>
                      <Link to="/products">Products</Link>
                    </nav>
                  </Header>
                  <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/about" element={<About />}>
                      <Route path="mission" element={<Mission />} />
                      <Route path="team" element={<Team />} />
                      <Route path="reviews" element={<Reviews />} />
                    </Route>
                    <Route path="/products" element={<Products />} />
                    <Route path="/products/:productId" element={<ProductDetails />} />
                  </Routes>
                </Container>
              );
            };
          

Teraz przenieśmy ten układ i jego style do oddzielnego komponentu SharedLayout. Zwróć uwagę na wykorzystanie i położenie komponentu Outlet - to w tym miejscu będą renderowały się komponenty poszczególnych stron.

            src/components/SharedLayout.jsx

            // Imports
            import { Outlet } from "react-router-dom";

            export const SharedLayout = () => {
              return (
                <Container>
                  <Header>
                    <Logo>
                      <span role="img" aria-label="computer icon">
                        💻
                      </span>{" "}
                      GoMerch Store
                    </Logo>
                    <nav>
                      <Link to="/">Home</Link>
                      <Link to="/about">About</Link>
                      <Link to="/products">Products</Link>
                    </nav>
                  </Header>
                  <Outlet />
                </Container>
              );
            };
          

Pozostaje wykorzystać nowy komponent w App, tak aby renderował się dla każdej ścieżki. W tym celu będziemy renderować go na adresie /, a wszystkie pozostałe ścieżki będą w nim zagnieżdżone.

            src/components/App.jsx
            
            // Imports
            import { SharedLayout } from "path/to/components/SharedLayout";

            export const App = () => {
              return (
                <Routes>
                  <Route path="/" element={<SharedLayout />}>
                    <Route path="about" element={<About />}>
                      <Route path="mission" element={<Mission />} />
                      <Route path="team" element={<Team />} />
                      <Route path="reviews" element={<Reviews />} />
                    </Route>
                    <Route path="products" element={<Products />} />
                    <Route path="products/:productId" element={<ProductDetails />} />
                  </Route>
                </Routes>
              );
            };
          

Zwróć uwagę na to, że zaktualizowane zostały również ścieżki dla propsa path - relatywnie do nowego rodzica /.

Możesz się zastanawiać gdzie znikł komponent Home, który wcześniej renderował się dla path="/"?. Przecież teraz na / renderuje się tylko SharedLayout... Trafne spostrzeżenie! Chcąc naprawić ten problem musimy dodać tzw. "ścieżkę indeksową".

            src/components/App.jsx
            
            // Imports
            import { SharedLayout } from "path/to/components/SharedLayout";

            export const App = () => {
              return (
                <Routes>
                  <Route path="/" element={<SharedLayout />}>
                    <Route index element={<Home />} />
                    <Route path="about" element={<About />}>
                      <Route path="mission" element={<Mission />} />
                      <Route path="team" element={<Team />} />
                      <Route path="reviews" element={<Reviews />} />
                    </Route>
                    <Route path="products" element={<Products />} />
                    <Route path="products/:productId" element={<ProductDetails />} />
                  </Route>
                </Routes>
              );
            };
          

"Indeksowa" może być tylko zagnieżdżona ścieżka. W jej Route nie wskazuje się propsa path, ponieważ chcemy aby jej path pokrywał się z tym rodzica. Zamiast tego przekazywany jest specjalny props index, który informuje router, że ścieżka indeksowa powinna zostać wyrenderowana pod tym samym adresem, co jego rodzic.

Może istnieć dowolna ilość ścieżek indeksowych, wszystko zależy od tego co chcemy osiągnąć. Na przykład, jeśli w naszej aplikacji byłyby strony panelu administratora, dla których ma obowiązywać zupełnie inny 'layout', to strukturę ścieżek można by było zaprojektować następująco.

            <Routes>
              <Route path="/" element={<SharedLayout />}>
                <Route index element={<Home />} />
                <Route path="about" element={<About />}>
                  <Route path="mission" element={<Mission />} />
                  <Route path="team" element={<Team />} />
                  <Route path="reviews" element={<Reviews />} />
                </Route>
                <Route path="products" element={<Products />} />
                <Route path="products/:productId" element={<ProductDetails />} />
              </Route>
              <Route path="/admin" element={<AdminLayout />}>
                <Route index element={<Dashboard />} />
                <Route path="sales" element={<Sales />} />
                <Route path="customers" element={<Customers />} />
              </Route>
            </Routes>