Kurs JavaScript i React · Moduł 5: useEffect i własne hooki

Przechowywanie wartości między renderowaniami z useRef

6 min czytania
W tej lekcji6

Wyobraź sobie taką sytuację: chcesz zapamiętać identyfikator timera misji albo poprzednią prędkość statku. Zwykła zmienna w komponencie odpada, bo przy każdym renderowaniu funkcja komponentu wykonuje się od nowa i zmienna wraca do wartości początkowej. useState zapamięta wartość, ale każda jej zmiana wywoła ponowne renderowanie, choć ekran wcale nie musi się zmienić. Potrzebujemy czegoś pośrodku.

W kosmicznej podróży przez React, czasami potrzebujemy przechowywać informacje, które przetrwają między renderowaniami komponentu, ale nie powinny powodować ponownego renderowania. Hook useRef jest jak kapsuła ratunkowa - przechowuje dane, które możesz odzyskać w dowolnym momencie, bez względu na to, ile razy twój statek kosmiczny (komponent) zostanie przebudowany.

Czym jest useRef?

Hook useRef tworzy mutowalny obiekt referencyjny, którego właściwość .current jest inicjalizowana przekazaną wartością. Ważną cechą useRef jest to, że:

  1. Wartość przechowywana w referencji pozostaje taka sama między renderowaniami
  2. Zmiana wartości referencji NIE powoduje ponownego renderowania komponentu
  3. Działa jak "magazyn pamięci", do którego możesz się odwoływać w dowolnym momencie w cyklu życia komponentu

Dokumentacja react.dev dodaje dwa szczegóły. Po pierwsze, useRef przy każdym renderowaniu zwraca ten sam obiekt, a wartość początkowa jest używana tylko przy pierwszym renderze. Po drugie, .current zmieniasz zwykłym przypisaniem, bez żadnej funkcji typu setter.

useRef vs useState - kluczowe różnice

Porównajmy dwie kluczowe metody przechowywania danych w komponentach. W poniższym panelu prędkość trzymamy w stanie, a poprzednią prędkość w referencji, którą aktualizuje useEffect po każdej zmianie:

1function SpaceshipDashboard() {
2  // Stan - zmiany powodują ponowne renderowanie
3  const [speed, setSpeed] = useState(0);
4
5  // Referencja - zmiany NIE powodują ponownego renderowania
6  const previousSpeedRef = useRef(0);
7
8  // Przy zmianie prędkości, zapisujemy poprzednią wartość
9  useEffect(() => {
10    previousSpeedRef.current = speed;
11  }, [speed]);
12
13  const increaseSpeed = () => {
14    setSpeed(speed + 10); // To spowoduje ponowne renderowanie
15  };
16
17  return (
18    <div>
19      <p>Aktualna prędkość: {speed} km/h</p>
20      <p>Poprzednia prędkość: {previousSpeedRef.current} km/h</p>
21      <button onClick={increaseSpeed}>Zwiększ prędkość</button>
22    </div>
23  );
24}

Kliknięcie zmienia stan, więc komponent renderuje się ponownie. W trakcie tego renderu referencja ma jeszcze starą wartość, bo efekt uruchamia się dopiero po wyrenderowaniu. Dlatego na ekranie widzisz poprzednią prędkość. Samo przypisanie do previousSpeedRef.current w efekcie niczego nie odświeża.

Ważne zastrzeżenie: react.dev zaleca, żeby nie czytać ani nie zapisywać ref.current w trakcie renderowania (wyjątkiem jest leniwa inicjalizacja). Ten wzorzec działa w prostym panelu, ale jest kruchy. Jeśli poprzednia wartość ma być widoczna na ekranie, dokumentacja poleca trzymać ją w stanie. Referencję zostaw na dane, których ekran nie pokazuje.

Typowe przypadki użycia useRef

1. Przechowywanie poprzednich wartości

Jednym z najpopularniejszych zastosowań useRef jest zapamiętywanie poprzednich wartości między renderowaniami. Tracker asteroidy liczy, o ile obiekt przesunął się od ostatniego renderu:

1function AsteroidTracker({ asteroid }) {
2  const [position, setPosition] = useState({ x: 0, y: 0 });
3  const previousPositionRef = useRef({ x: 0, y: 0 });
4
5  useEffect(() => {
6    // Zapisujemy aktualną pozycję jako poprzednią przed następną aktualizacją
7    previousPositionRef.current = position;
8  }, [position]);
9
10  // Obliczamy, jak daleko poruszył się asteroid
11  const distanceMoved = Math.sqrt(
12    Math.pow(position.x - previousPositionRef.current.x, 2) +
13    Math.pow(position.y - previousPositionRef.current.y, 2)
14  );
15
16  return (
17    <div>
18      <p>Aktualna pozycja: ({position.x}, {position.y})</p>
19      <p>Poprzednia pozycja: ({previousPositionRef.current.x}, {previousPositionRef.current.y})</p>
20      <p>Przebyta odległość: {distanceMoved}</p>
21    </div>
22  );
23}

Mechanizm jest ten sam co w panelu prędkości: efekt zapisuje pozycję po renderze, a obliczenie korzysta z wartości z poprzedniego renderu. Obowiązuje tu też to samo zastrzeżenie o czytaniu referencji podczas renderowania.

2. Liczniki i wartości instancji, które nie wpływają na renderowanie

Tu useRef naprawdę błyszczy. Czas misji i identyfikator interwału to dane, które zmieniają się co sekundę, ale nie są wyświetlane na bieżąco:

1function MissionControl() {
2  const missionTimeRef = useRef(0);
3  const timerIdRef = useRef(null);
4
5  // Rozpoczęcie licznika misji
6  const startMissionTimer = () => {
7    if (timerIdRef.current !== null) return; // Unikamy wielokrotnego uruchamiania
8
9    timerIdRef.current = setInterval(() => {
10      missionTimeRef.current += 1;
11      console.log(`Czas misji: ${missionTimeRef.current} sekund`);
12      // Zauważ, że to NIE powoduje ponownego renderowania komponentu
13    }, 1000);
14  };
15
16  // Zatrzymanie licznika misji
17  const stopMissionTimer = () => {
18    clearInterval(timerIdRef.current);
19    timerIdRef.current = null;
20  };
21
22  // Czyszczenie przy odmontowaniu komponentu
23  useEffect(() => {
24    return () => {
25      if (timerIdRef.current !== null) {
26        clearInterval(timerIdRef.current);
27      }
28    };
29  }, []);
30
31  return (
32    <div>
33      <button onClick={startMissionTimer}>Rozpocznij misję</button>
34      <button onClick={stopMissionTimer}>Zakończ misję</button>
35      <button onClick={() => alert(`Czas misji: ${missionTimeRef.current} sekund`)}>
36        Pokaż czas misji
37      </button>
38    </div>
39  );
40}

Licznik rośnie co sekundę, konsola to potwierdza, a komponent nie renderuje się ani razu. Referencje czytamy wyłącznie w funkcjach obsługi zdarzeń i w efekcie, czyli dokładnie tam, gdzie zaleca dokumentacja. Przyciski i ich wygląd pozostają niezmienione przez całą misję.

3. Dostęp do elementów DOM

Trzecie zastosowanie to uchwyt do prawdziwego elementu na stronie. Przekazujesz referencję w atrybucie ref, a React po wyrenderowaniu zapisze w .current węzeł DOM:

1function SpaceshipScanner() {
2  const scannerInputRef = useRef(null);
3
4  const focusScannerInput = () => {
5    // Bezpośredni dostęp do elementu DOM
6    scannerInputRef.current.focus();
7  };
8
9  return (
10    <div>
11      <input
12        ref={scannerInputRef}
13        type="text"
14        placeholder="Wprowadź współrzędne..."
15      />
16      <button onClick={focusScannerInput}>Aktywuj skaner</button>
17    </div>
18  );
19}

Kliknięcie przycisku ustawia kursor w polu współrzędnych. React nie tworzy tu nowego pola, tylko daje ci dostęp do istniejącego. W React 19 możesz też przekazać ref jako zwykły prop do własnego komponentu funkcyjnego, bez forwardRef.

useRef a useCallback

Oba hooki coś "zapamiętują", ale co innego. useRef trzyma dowolną wartość, którą sam zmieniasz. useCallback zapamiętuje funkcję i tworzy nową dopiero wtedy, gdy zmieni się któraś z zależności:

1const handleClick = useCallback(() => {
2  onClick(id);
3}, [id, onClick]);

Kolejność jest zawsze taka sama: funkcja, potem tablica zależności. Dopóki id i onClick się nie zmienią, handleClick pozostaje tym samym obiektem funkcji.

Pamiętaj o zasadach używania useRef

  1. Nie nadużywaj go: Używaj useRef tylko wtedy, gdy wartość naprawdę nie powinna powodować ponownego renderowania
  2. Unikaj bezpośrednich mutacji DOM: Chociaż useRef daje ci dostęp do elementów DOM, używaj go oszczędnie i zgodnie z filozofią Reacta
  3. Pamiętaj o czyszczeniu: Jeśli używasz useRef do przechowywania timerów lub subskrypcji, pamiętaj o ich czyszczeniu w funkcji czyszczącej useEffect

Moja rada: zanim sięgniesz po referencję, zapytaj, czy ta wartość ma być widoczna na ekranie. Jeśli tak, użyj stanu. Jeśli nie, referencja będzie lżejsza.

Podsumowanie

Hook useRef jest nieoceniony, gdy potrzebujesz:

  • Przechować wartość między renderowaniami bez powodowania ponownego renderowania
  • Śledzić poprzednie wartości
  • Uzyskać bezpośredni dostęp do elementów DOM
  • Przechowywać identyfikatory timerów lub inne wartości instancji

W następnej lekcji zbudujemy własne hooki, w których referencje często ukrywają szczegóły, na przykład timery.

Jak kapsuła ratunkowa w kosmosie, useRef przechowuje cenne dane, które mogą być potrzebne w krytycznych momentach misji twojego komponentu.

Kod do tej lekcji: App.jsx
1import React, { useState, useRef, useEffect } from 'react';
2
3// Śledzenie poprzednich wartości
4function PreviousValueTracker() {
5  const [position, setPosition] = useState({ x: 0, y: 0 });
6  const prevPositionRef = useRef({ x: 0, y: 0 });
7
8  useEffect(() => {
9    prevPositionRef.current = position;
10  });
11
12  const move = (dx, dy) => {
13    setPosition(prev => ({
14      x: Math.max(-100, Math.min(100, prev.x + dx)),
15      y: Math.max(-100, Math.min(100, prev.y + dy))
16    }));
17  };
18
19  return (
20    <div className="demo-card">
21      <h2>Sledzenie pozycji</h2>
22      <div className="position-display">
23        <div className="current">
24          <h3>Aktualna</h3>
25          <p>X: {position.x}</p>
26          <p>Y: {position.y}</p>
27        </div>
28        <div className="previous">
29          <h3>Poprzednia</h3>
30          <p>X: {prevPositionRef.current.x}</p>
31          <p>Y: {prevPositionRef.current.y}</p>
32        </div>
33      </div>
34      <div className="controls">
35        <button onClick={() => move(0, -10)}>UP</button>
36        <div className="horizontal">
37          <button onClick={() => move(-10, 0)}>LEFT</button>
38          <button onClick={() => move(10, 0)}>RIGHT</button>
39        </div>
40        <button onClick={() => move(0, 10)}>DOWN</button>
41      </div>
42    </div>
43  );
44}
45
46// Licznik renderow
47function RenderCounter() {
48  const renderCountRef = useRef(0);
49  const [value, setValue] = useState('');
50
51  renderCountRef.current++;
52
53  return (
54    <div className="demo-card">
55      <h2>Licznik renderow</h2>
56      <div className="render-count">
57        Rendery: <span>{renderCountRef.current}</span>
58      </div>
59      <input
60        value={value}
61        onChange={(e) => setValue(e.target.value)}
62        placeholder="Pisz, aby wywolac render..."
63      />
64      <p className="hint">
65        useRef przechowuje wartosc bez ponownego renderowania
66      </p>
67    </div>
68  );
69}
70
71// Porownanie obiektow
72function ObjectComparisonDemo() {
73  const [user, setUser] = useState({ name: 'Astronauta', level: 1 });
74  const prevUserRef = useRef(user);
75  const [changes, setChanges] = useState([]);
76
77  useEffect(() => {
78    const changedFields = [];
79
80    if (prevUserRef.current.name !== user.name) {
81      changedFields.push(`name: "${prevUserRef.current.name}" -> "${user.name}"`);
82    }
83    if (prevUserRef.current.level !== user.level) {
84      changedFields.push(`level: ${prevUserRef.current.level} -> ${user.level}`);
85    }
86
87    if (changedFields.length > 0) {
88      setChanges(prev => [...prev.slice(-4), ...changedFields]);
89    }
90
91    prevUserRef.current = user;
92  }, [user]);
93
94  return (
95    <div className="demo-card">
96      <h2>Porownanie zmian</h2>
97      <div className="user-info">
98        <input
99          value={user.name}
100          onChange={(e) => setUser(prev => ({ ...prev, name: e.target.value }))}
101          placeholder="Imie"
102        />
103        <div className="level-controls">
104          <button onClick={() => setUser(prev => ({ ...prev, level: Math.max(1, prev.level - 1) }))}>
105            -
106          </button>
107          <span>Level: {user.level}</span>
108          <button onClick={() => setUser(prev => ({ ...prev, level: prev.level + 1 }))}>
109            +
110          </button>
111        </div>
112      </div>
113      <div className="changes">
114        <h4>Historia zmian:</h4>
115        {changes.map((change, i) => (
116          <div key={i} className="change">{change}</div>
117        ))}
118      </div>
119    </div>
120  );
121}
122
123// Cache wartosci obliczeniowych
124function ComputationCacheDemo() {
125  const [numbers, setNumbers] = useState([1, 2, 3, 4, 5]);
126  const [multiplier, setMultiplier] = useState(2);
127  const cacheRef = useRef(new Map());
128  const cacheHits = useRef(0);
129  const cacheMisses = useRef(0);
130
131  const computeExpensive = (num, mult) => {
132    const key = `${num}-${mult}`;
133
134    if (cacheRef.current.has(key)) {
135      cacheHits.current += 1;
136      return cacheRef.current.get(key);
137    }
138
139    // Symulacja kosztownej operacji
140    const result = num * mult;
141    cacheRef.current.set(key, result);
142    cacheMisses.current += 1;
143    return result;
144  };
145
146  const results = numbers.map(n => computeExpensive(n, multiplier));
147
148  const addNumber = () => {
149    setNumbers(prev => [...prev, prev.length + 1]);
150  };
151
152  return (
153    <div className="demo-card">
154      <h2>Cache z useRef</h2>
155      <div className="cache-stats">
156        <span className="hit">Trafienia: {cacheHits.current}</span>
157        <span className="miss">Pudla: {cacheMisses.current}</span>
158      </div>
159      <div className="multiplier">
160        <button onClick={() => setMultiplier(m => Math.max(1, m - 1))}>-</button>
161        <span>Mnoznik: {multiplier}</span>
162        <button onClick={() => setMultiplier(m => m + 1)}>+</button>
163      </div>
164      <div className="numbers">
165        {numbers.map((n, i) => (
166          <div key={i} className="number-result">
167            {n} x {multiplier} = {results[i]}
168          </div>
169        ))}
170      </div>
171      <button onClick={addNumber}>+ Dodaj liczbe</button>
172    </div>
173  );
174}
175
176function App() {
177  return (
178    <div className="app">
179      <h1>Przechowywanie wartosci z useRef</h1>
180      <div className="demos">
181        <PreviousValueTracker />
182        <RenderCounter />
183        <ObjectComparisonDemo />
184        <ComputationCacheDemo />
185      </div>
186    </div>
187  );
188}
189
190export default App;

Widzisz błąd w tej lekcji?

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż poprawną składnię useCallback dla handlera zdarzenia:

Przydatne artykuły