Kurs JavaScript i React · Moduł 6: React Router
Obsługa historii przeglądarki w podróży przez kosmiczny czas
W tej lekcji12
W międzygalaktycznych podróżach, ważne jest śledzenie trasy i możliwość powrotu do poprzednich punktów eksploracji. W świecie React Router, tym systemem nawigacji jest historia przeglądarki - mechanizm, który pozwala na poruszanie się wstecz i wprzód między odwiedzonymi stronami. Zobaczmy, jak świadomie zarządzać historią, aby zwiększyć komfort podróży użytkowników przez naszą aplikację.
Podstawy historii przeglądarki
Historia przeglądarki to stos odwiedzonych przez użytkownika adresów URL. Przeglądarki zapewniają API, które pozwala na:
- Przechodzenie wstecz i wprzód między stronami
- Dodawanie nowych wpisów do historii
- Zastępowanie bieżącego wpisu
- Manipulowanie stanem historii
React Router integruje się z tym API, dostarczając hooki i komponenty do zarządzania historią w sposób przyjazny dla Reacta.
Hook useNavigate
Hook useNavigate jest podstawowym narzędziem do programowej nawigacji w React Router:
1import { useNavigate } from 'react-router-dom';
2
3function MissionControl() {
4 const navigate = useNavigate();
5
6 const goToExploration = () => {
7 navigate('/exploration');
8 };
9
10 const goBack = () => {
11 navigate(-1); // Cofnij o jedną stronę
12 };
13
14 const goForward = () => {
15 navigate(1); // Przejdź o jedną stronę do przodu
16 };
17
18 const jumpToSpecificPoint = () => {
19 navigate(-3); // Cofnij o trzy strony
20 };
21
22 return (
23 <div className="mission-control">
24 <h1>Centrum Sterowania Misją</h1>
25
26 <div className="navigation-panel">
27 <button onClick={goToExploration}>Rozpocznij eksplorację</button>
28 <button onClick={goBack}>Powrót do poprzedniej lokalizacji</button>
29 <button onClick={goForward}>Do przodu</button>
30 <button onClick={jumpToSpecificPoint}>Powrót do punktu wyjścia</button>
31 </div>
32 </div>
33 );
34}Zastępowanie vs. dodawanie w historii
Przy użyciu navigate możemy zdecydować, czy chcemy dodać nową stronę do historii, czy zastąpić bieżącą:
1// Dodanie nowej strony do stosu historii (domyślne zachowanie)
2navigate('/new-location');
3
4// Zastąpienie bieżącej strony w historii
5navigate('/new-location', { replace: true });Kiedy warto używać replace: true?
- Przekierowania po logowaniu/wylogowaniu - nie chcemy, aby użytkownik mógł wrócić do strony logowania po zalogowaniu
- Formularze wieloetapowe - nie chcemy, aby użytkownik mógł wrócić do pośrednich kroków procesu
- Przekierowania z nieistniejących stron - gdy przekierowujemy z 404 do strony głównej
- Kiedy realizujemy przekierowanie - aby nie powodować pętli w historii przeglądarki
Blokowanie nawigacji
Czasami potrzebujemy zapobiec opuszczeniu strony przez użytkownika, na przykład gdy ma niezapisane zmiany w formularzu. Wyobraź sobie konfigurator statku: astronauta wpisał nazwę statku i dobrał silniki, ale jeszcze nie zapisał konfiguracji, i przypadkiem klika link do hangaru albo przycisk "Wstecz". Bez ostrzeżenia cała praca przepada.
React Router ma do tego wbudowany hook useBlocker. Pojawił się w wersji 6.7 jako unstable_useBlocker, od wersji 6.19 jest stabilny i działa także w v7 i v8. Warunek znasz z lekcji o zagnieżdżonych ścieżkach: hook działa tylko w routerze danych (data router), czyli gdy trasy przekazujesz funkcji createBrowserRouter, a aplikację renderujesz przez <RouterProvider>. W zwykłym <BrowserRouter> rzuci błąd "useBlocker must be used within a data router".
1import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2
3const router = createBrowserRouter([
4 { path: '/', element: <MissionControl /> },
5 { path: '/configurator', element: <SpaceshipConfigurator /> },
6]);
7
8function App() {
9 return <RouterProvider router={router} />;
10}Trasy opisuje tu tablica obiektów, a komponenty stron, takie jak MissionControl, zostają bez zmian. W v8 createBrowserRouter i useBlocker importujesz z pakietu react-router, a RouterProvider z react-router/dom.
Teraz sam hook. useBlocker przyjmuje wartość logiczną albo funkcję. Funkcja dostaje obiekt z polami currentLocation, nextLocation i historyAction i zwraca true, gdy nawigację trzeba zatrzymać. Hook zwraca obiekt blokady z polem state: 'unblocked' (nic nie jest zablokowane), 'blocked' (nawigacja wstrzymana) albo 'proceeding' (trwa zatwierdzone przejście). W stanie 'blocked' pole location wskazuje, dokąd użytkownik chciał przejść, a do dyspozycji masz dwie metody: proceed() wznawia nawigację, a reset() ją anuluje i zostawia użytkownika na miejscu.
1import { useState } from 'react';
2import { useBlocker } from 'react-router-dom';
3
4function SpaceshipConfigurator() {
5 const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
6 const [config, setConfig] = useState({});
7
8 // Blokujemy tylko przy niezapisanych zmianach i zmianie ścieżki
9 const blocker = useBlocker(
10 ({ currentLocation, nextLocation }) =>
11 hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname
12 );
13
14 return (
15 <div className="configurator">
16 <input
17 value={config.shipName ?? ''}
18 onChange={(e) => {
19 setConfig({ ...config, shipName: e.target.value });
20 setHasUnsavedChanges(true);
21 }}
22 placeholder="Nazwa statku"
23 />
24 <button onClick={() => setHasUnsavedChanges(false)}>Zapisz</button>
25
26 {blocker.state === 'blocked' && (
27 <div className="warning-panel">
28 <p>Masz niezapisane zmiany. Czy na pewno chcesz opuścić tę stronę?</p>
29 <button onClick={() => blocker.proceed()}>Opuść stronę</button>
30 <button onClick={() => blocker.reset()}>Zostań</button>
31 </div>
32 )}
33 </div>
34 );
35}Blokada obejmuje kliknięcia w Link, wywołania navigate() oraz przyciski "Wstecz" i "Dalej" przeglądarki, a po kliknięciu "Zapisz" funkcja zwraca false i nawigacja znów przebiega swobodnie. Hook nie wyświetla żadnego okna, więc panel ostrzegawczy projektujesz sam, w stylu swojej aplikacji. Istnieje też unstable_usePrompt oparty na window.confirm, ale twórcy celowo zostawili go jako niestabilny, bo przeglądarki różnie obsługują window.confirm.
useBlocker nie pomoże jednak, gdy użytkownik zamyka kartę, odświeża stronę albo wpisuje w pasku inny adres. Wtedy przeglądarka wyładowuje całą stronę i zostaje tylko zdarzenie beforeunload. Obsłużymy je w małym własnym hooku:
1import { useEffect } from 'react';
2
3function useBeforeUnloadWarning(when) {
4 useEffect(() => {
5 if (!when) return;
6
7 const handleBeforeUnload = (e) => {
8 e.preventDefault();
9 e.returnValue = true; // dla starszych przeglądarek
10 };
11
12 window.addEventListener('beforeunload', handleBeforeUnload);
13 return () => {
14 window.removeEventListener('beforeunload', handleBeforeUnload);
15 };
16 }, [when]);
17}W SpaceshipConfigurator wystarczy wywołać useBeforeUnloadWarning(hasUnsavedChanges). Nasłuch istnieje tylko wtedy, gdy są niezapisane zmiany, a funkcja sprzątająca usuwa go, gdy zmiany znikną. Tak zaleca MDN, bo w Firefoksie strona z nasłuchem beforeunload nie trafia do pamięci bfcache i powrót do niej trwa dłużej. Przeglądarka pokaże przy tym własny, ogólny komunikat zamiast Twojego tekstu, i to tylko wtedy, gdy użytkownik wcześniej wszedł w interakcję ze stroną.
Polecam zawsze łączyć oba mechanizmy: useBlocker pilnuje nawigacji wewnątrz aplikacji, a beforeunload zamknięcia karty i odświeżenia strony.
Hook useLocation
Hook useLocation daje nam dostęp do bieżącego obiektu location, który zawiera informacje o aktualnym URL:
1import { useLocation } from 'react-router-dom';
2
3function LocationTracker() {
4 const location = useLocation();
5
6 return (
7 <div>
8 <h2>Aktualna Lokalizacja Kosmiczna</h2>
9 <p>Ścieżka: {location.pathname}</p>
10 <p>Query: {location.search}</p>
11 <p>Hash: {location.hash}</p>
12 <p>Kluczowy identyfikator: {location.key}</p>
13 <p>Metoda nawigacji: {location.state?.navigationMethod || 'Standardowa'}</p>
14 </div>
15 );
16}Pole location.key jest szczególnie przydatne - jest to unikalny identyfikator dla każdego wpisu w historii. Możemy go wykorzystać do wykrywania, kiedy użytkownik powraca do strony, którą już odwiedził.
Śledzenie historii nawigacji
Możemy stworzyć własny hook do śledzenia historii nawigacji:
1import { useState, useEffect } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useNavigationHistory(maxEntries = 10) {
5 const location = useLocation();
6 const [history, setHistory] = useState([]);
7
8 useEffect(() => {
9 setHistory(prev => {
10 // Dodanie bieżącej lokalizacji do historii
11 const newHistory = [...prev, {
12 pathname: location.pathname,
13 search: location.search,
14 hash: location.hash,
15 key: location.key,
16 timestamp: Date.now()
17 }];
18
19 // Ograniczenie rozmiaru historii
20 return newHistory.slice(-maxEntries);
21 });
22 }, [location, maxEntries]);
23
24 return history;
25}
26
27// Użycie:
28function NavigationLogger() {
29 const history = useNavigationHistory(5);
30
31 return (
32 <div className="navigation-log">
33 <h3>Dziennik Podróży</h3>
34 <ul>
35 {history.map((entry, index) => (
36 <li key={entry.key}>
37 {index === history.length - 1 ? '' : ''}
38 {entry.pathname} (odwiedzono: {new Date(entry.timestamp).toLocaleTimeString()})
39 </li>
40 ))}
41 </ul>
42 </div>
43 );
44}Modyfikowanie zachowania przycisku "Wstecz"
Czasami chcemy zmienić domyślne zachowanie przycisku "Wstecz" w przeglądarce. Na przykład, jeśli użytkownik jest na stronie, którą może odwiedzić tylko po zalogowaniu, a kliknie "Wstecz", możemy go przekierować do bezpiecznej lokalizacji:
1import { useEffect } from 'react';
2import { useNavigate, useLocation } from 'react-router-dom';
3
4function SecureZone() {
5 const navigate = useNavigate();
6 const location = useLocation();
7 const { isAuthenticated } = useAuth();
8
9 useEffect(() => {
10 // Sprawdzamy, czy użytkownik jest zalogowany przy każdej zmianie lokalizacji
11 if (!isAuthenticated) {
12 navigate('/login', { replace: true });
13 }
14
15 // Zmodyfikowane zachowanie przycisku "Wstecz"
16 const handlePopState = () => {
17 if (!isAuthenticated) {
18 // Jeśli użytkownik nie jest zalogowany i kliknie "Wstecz",
19 // przekieruj go do strony głównej zamiast poprzedniej strony
20 navigate('/', { replace: true });
21 }
22 };
23
24 window.addEventListener('popstate', handlePopState);
25
26 return () => {
27 window.removeEventListener('popstate', handlePopState);
28 };
29 }, [isAuthenticated, navigate, location]);
30
31 return (
32 <div className="secure-zone">
33 <h1>Strefa Zabezpieczona</h1>
34 <p>Witaj w Centrum Dowodzenia. Masz dostęp do tajnych danych.</p>
35 </div>
36 );
37}Synchronizacja historii z globalnym stanem
W bardziej złożonych aplikacjach, możemy chcieć synchronizować historię przeglądarki z globalnym stanem aplikacji (np. Redux):
1import { useEffect } from 'react';
2import { useLocation } from 'react-router-dom';
3import { useDispatch } from 'react-redux';
4
5function HistorySync() {
6 const location = useLocation();
7 const dispatch = useDispatch();
8
9 useEffect(() => {
10 // Za każdym razem, gdy zmienia się lokalizacja, aktualizujemy stan Redux
11 dispatch({
12 type: 'navigation/locationChanged',
13 payload: {
14 pathname: location.pathname,
15 search: location.search,
16 hash: location.hash,
17 state: location.state
18 }
19 });
20 }, [location, dispatch]);
21
22 return null; // Komponent nie renderuje niczego
23}
24
25// Użycie w App:
26function App() {
27 return (
28 <Router>
29 <HistorySync />
30 {/* Reszta aplikacji */}
31 </Router>
32 );
33}Analizowanie parametrów URL
Często potrzebujemy analizować parametry zapytania z URL:
1import { useLocation } from 'react-router-dom';
2
3function useParsedQuery() {
4 const location = useLocation();
5 const queryParams = new URLSearchParams(location.search);
6
7 return {
8 get: (param) => queryParams.get(param),
9 has: (param) => queryParams.has(param),
10 getAll: (param) => queryParams.getAll(param),
11 toString: () => queryParams.toString(),
12 entries: () => Array.from(queryParams.entries()).reduce((acc, [key, value]) => {
13 acc[key] = value;
14 return acc;
15 }, {})
16 };
17}
18
19// Użycie:
20function SpaceshipFilter() {
21 const query = useParsedQuery();
22 const typeFilter = query.get('type'); // np. 'exploration'
23 const minCrewSize = query.get('minCrew'); // np. '3'
24
25 return (
26 <div>
27 <h2>Filtrowanie Statków Kosmicznych</h2>
28 {typeFilter && <p>Filtrowanie wg typu: {typeFilter}</p>}
29 {minCrewSize && <p>Minimalna wielkość załogi: {minCrewSize}</p>}
30
31 {/* Reszta komponentu */}
32 </div>
33 );
34}History Stack i zarządzanie głębokością
Czasami chcemy sprawdzić, jak głęboko w historii przeglądarki znajduje się użytkownik, lub wykonać akcję po osiągnięciu określonej głębokości:
1import { useState, useEffect } from 'react';
2
3function useHistoryDepth() {
4 const [depth, setDepth] = useState(0);
5
6 useEffect(() => {
7 // Ustaw początkową głębokość
8 setDepth(window.history.length);
9
10 // Nasłuchuj na zmiany
11 const handleNavigation = () => {
12 setDepth(window.history.length);
13 };
14
15 window.addEventListener('popstate', handleNavigation);
16
17 return () => {
18 window.removeEventListener('popstate', handleNavigation);
19 };
20 }, []);
21
22 return depth;
23}
24
25// Użycie:
26function NavigationDepthIndicator() {
27 const depth = useHistoryDepth();
28
29 return (
30 <div className="depth-indicator">
31 <p>Głębokość historii: {depth}</p>
32 {depth > 10 && (
33 <button onClick={() => window.history.go(-5)}>
34 Powrót o 5 stron
35 </button>
36 )}
37 </div>
38 );
39}Zaawansowane scenariusze
1. Przywracanie przewijania
Standardowe zachowanie przeglądarki to przywracanie pozycji przewijania przy nawigacji wstecz. W aplikacjach SPA musimy często implementować to ręcznie:
1import { useEffect, useRef } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useScrollRestoration() {
5 const location = useLocation();
6 const scrollPositions = useRef({});
7
8 // Zapisz pozycję przewijania przy zmianie lokalizacji
9 useEffect(() => {
10 const saveScrollPosition = (key) => {
11 scrollPositions.current[key] = window.scrollY;
12 };
13
14 // Zapisz bieżącą pozycję, gdy użytkownik opuszcza stronę
15 return () => {
16 saveScrollPosition(location.key);
17 };
18 }, [location]);
19
20 // Przywróć pozycję przewijania, gdy użytkownik powraca do strony
21 useEffect(() => {
22 if (scrollPositions.current[location.key] !== undefined) {
23 // Przywróć zapisaną pozycję
24 window.scrollTo(0, scrollPositions.current[location.key]);
25 } else {
26 // Nowa strona, przewiń do góry
27 window.scrollTo(0, 0);
28 }
29 }, [location]);
30}
31
32// Użycie:
33function ScrollRestoringPage() {
34 useScrollRestoration();
35
36 return (
37 <div className="long-content-page">
38 {/* Dużo treści powodującej przewijanie */}
39 </div>
40 );
41}2. Wykrywanie zmiany parametrów
Czasami potrzebujemy wiedzieć, czy zmieniły się konkretne parametry w URL:
1import { useEffect, useRef } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useQueryParamChange(paramName) {
5 const location = useLocation();
6 const previousParam = useRef(null);
7
8 // Pobierz aktualną wartość parametru
9 const query = new URLSearchParams(location.search);
10 const currentValue = query.get(paramName);
11
12 // Sprawdź, czy wartość się zmieniła
13 const hasChanged = previousParam.current !== currentValue;
14
15 // Aktualizuj ref dla następnego renderowania
16 useEffect(() => {
17 previousParam.current = currentValue;
18 }, [currentValue]);
19
20 return {
21 value: currentValue,
22 hasChanged,
23 previousValue: previousParam.current
24 };
25}
26
27// Użycie:
28function GalaxyFilter() {
29 const { value: galaxyType, hasChanged } = useQueryParamChange('galaxy');
30
31 useEffect(() => {
32 if (hasChanged) {
33 // Wykonaj akcję przy zmianie typu galaktyki
34 console.log(`Zmieniono filtr na: ${galaxyType}`);
35 // np. pobierz nowe dane
36 }
37 }, [galaxyType, hasChanged]);
38
39 return (
40 <div>
41 {/* Komponenty filtrowania */}
42 </div>
43 );
44}Podsumowanie
Świadome zarządzanie historią przeglądarki jest jak prowadzenie dziennika podróży kosmicznej - pozwala nam na:
- Nawigację w czasie - użytkownik może łatwo powracać do odwiedzonych lokalizacji
- Kontrolę przepływu - możemy wpływać na to, jak użytkownik porusza się po aplikacji
- Zachowanie kontekstu - dane stanu mogą być przenoszone między lokalizacjami
- Optymalizację doświadczenia - przywracanie stanu, pozycji przewijania itp.
Dobrze zaprojektowana obsługa historii przeglądarki sprawia, że podróż przez naszą aplikację jest intuicyjna i przyjemna, podobnie jak dobrze zaplanowana misja kosmiczna.
Pamiętaj, że historia przeglądarki to nie tylko mechanizm nawigacyjny, ale również ważny element doświadczenia użytkownika. Użytkownicy oczekują, że przyciski "Wstecz" i "Dalej" będą działać w sposób intuicyjny, a Twoja aplikacja powinna to respektować.
Kod do tej lekcji: App.jsx
1import React, { Suspense, lazy, useState } from 'react';
2import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
3
4// Lazy loaded components
5const Dashboard = lazy(() => {
6 // Symulacja opóźnienia ładowania
7 return new Promise(resolve => {
8 setTimeout(() => {
9 resolve({
10 default: () => (
11 <div className="page">
12 <h1>Dashboard</h1>
13 <p>Ten komponent został załadowany leniwie!</p>
14 <div className="stats">
15 <div className="stat">Użytkownicy: 1,234</div>
16 <div className="stat">Sesje: 5,678</div>
17 <div className="stat">Przychód: 9,999 zł</div>
18 </div>
19 </div>
20 )
21 });
22 }, 1500);
23 });
24});
25
26const Analytics = lazy(() => {
27 return new Promise(resolve => {
28 setTimeout(() => {
29 resolve({
30 default: () => (
31 <div className="page">
32 <h1>Analytics</h1>
33 <p>Zaawansowane statystyki załadowane leniwie.</p>
34 <div className="chart">
35 <div className="bar" style={{height: '60%'}}></div>
36 <div className="bar" style={{height: '80%'}}></div>
37 <div className="bar" style={{height: '45%'}}></div>
38 <div className="bar" style={{height: '90%'}}></div>
39 <div className="bar" style={{height: '70%'}}></div>
40 </div>
41 </div>
42 )
43 });
44 }, 2000);
45 });
46});
47
48const Settings = lazy(() => {
49 return new Promise(resolve => {
50 setTimeout(() => {
51 resolve({
52 default: () => (
53 <div className="page">
54 <h1>Ustawienia</h1>
55 <p>Konfiguracja systemu.</p>
56 <div className="settings-list">
57 <div className="setting">Tryb ciemny: Włączony</div>
58 <div className="setting">Powiadomienia: Włączone</div>
59 <div className="setting">Język: Polski</div>
60 </div>
61 </div>
62 )
63 });
64 }, 1000);
65 });
66});
67
68// Loading fallback component
69function LoadingSpinner() {
70 return (
71 <div className="loading">
72 <div className="spinner"></div>
73 <p>Ładowanie modułu...</p>
74 </div>
75 );
76}
77
78// Strona główna (nie lazy)
79function HomePage() {
80 return (
81 <div className="page">
82 <h1>Strona główna</h1>
83 <p>Ta strona jest załadowana natychmiast.</p>
84 <p className="info">Kliknij na inne linki, aby zobaczyć lazy loading w akcji!</p>
85 </div>
86 );
87}
88
89function App() {
90 return (
91 <BrowserRouter>
92 <div className="app">
93 <nav>
94 <Link to="/">Home (instant)</Link>
95 <Link to="/dashboard">Dashboard (1.5s)</Link>
96 <Link to="/analytics">Analytics (2s)</Link>
97 <Link to="/settings">Settings (1s)</Link>
98 </nav>
99
100 <Suspense fallback={<LoadingSpinner />}>
101 <Routes>
102 <Route path="/" element={<HomePage />} />
103 <Route path="/dashboard" element={<Dashboard />} />
104 <Route path="/analytics" element={<Analytics />} />
105 <Route path="/settings" element={<Settings />} />
106 </Routes>
107 </Suspense>
108 </div>
109 </BrowserRouter>
110 );
111}
112
113export default App;Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Które informacje dostarcza hook useLocation?
2. Co robi wywołanie navigate(-1) w React Router?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Śledzenie lokalizacji
- Układanie w poziomie
Ułóż składniki URL w kolejności ich występowania w adresie: