Kurs JavaScript i React · Moduł 3: Stan i zdarzenia

Aktualizacja stanu - wzorce i pułapki

6 min czytania
W tej lekcji8

Wyobraź sobie, że systemy nawigacyjne twojego statku kosmicznego działają na podstawie danych o stanie - prędkość, kurs, poziom paliwa. Każda aktualizacja tych parametrów musi być precyzyjna, bo nawet drobny błąd może wytrącić statek z orbity. W React aktualizacja stanu podlega podobnym zasadom - musisz znać wzorce bezpiecznej aktualizacji, aby twoja aplikacja działała przewidywalnie.

Aktualizacja funkcyjna vs bezpośrednia wartość

Gdy wywołujesz setState, masz dwie opcje. Pierwsza to przekazanie nowej wartości wprost:

1setCount(count + 1);

Druga to aktualizacja funkcyjna - przekazanie funkcji, która otrzymuje poprzednią wartość stanu:

1setCount(prev => prev + 1);

Dlaczego to ma znaczenie? React grupuje aktualizacje stanu (batching) i nie wykonuje ich natychmiast. Jeśli w jednym handlerze wywołasz setCount(count + 1) kilka razy, każde wywołanie zobaczy tę samą wartość count:

1function FuelGauge() {
2  const [fuel, setFuel] = useState(50);
3
4  const handleTripleBoost = () => {
5    // Wszystkie trzy widzą tę samą wartość fuel!
6    // Wynik: fuel wzrośnie tylko o 10, nie o 30
7    setFuel(fuel + 10);
8    setFuel(fuel + 10);
9    setFuel(fuel + 10);
10  };
11
12  const handleTripleBoostCorrect = () => {
13    // Każde wywołanie otrzymuje aktualną wartość
14    // Wynik: fuel wzrośnie o 30
15    setFuel(prev => prev + 10);
16    setFuel(prev => prev + 10);
17    setFuel(prev => prev + 10);
18  };
19
20  return (
21    <div>
22      <p>Paliwo: {fuel}%</p>
23      <button onClick={handleTripleBoost}>Boost (błędny)</button>
24      <button onClick={handleTripleBoostCorrect}>Boost (poprawny)</button>
25    </div>
26  );
27}

Zasada: Zawsze używaj aktualizacji funkcyjnej (prev => ...), gdy nowa wartość zależy od poprzedniej.

Wiele zmiennych stanu vs jeden obiekt

Masz dwie strategie przechowywania stanu w komponencie. Pierwsza to oddzielne zmienne stanu:

1function ShipDashboard() {
2  const [speed, setSpeed] = useState(0);
3  const [altitude, setAltitude] = useState(0);
4  const [heading, setHeading] = useState(0);
5
6  // Każda zmienna aktualizowana niezależnie
7  const accelerate = () => setSpeed(prev => prev + 10);
8  const climb = () => setAltitude(prev => prev + 100);
9
10  return (
11    <div>
12      <p>Prędkość: {speed} km/s | Wysokość: {altitude} km | Kurs: {heading}°</p>
13      <button onClick={accelerate}>Przyspiesz</button>
14      <button onClick={climb}>Wznieś się</button>
15    </div>
16  );
17}

Druga to stan w obiekcie - przydatna gdy dane są ze sobą powiązane:

1function ShipDashboard() {
2  const [navigation, setNavigation] = useState({
3    speed: 0,
4    altitude: 0,
5    heading: 0
6  });
7
8  // WAŻNE: zawsze kopiuj cały obiekt operatorem spread!
9  const accelerate = () => {
10    setNavigation(prev => ({ ...prev, speed: prev.speed + 10 }));
11  };
12
13  return (
14    <div>
15      <p>Prędkość: {navigation.speed} km/s</p>
16      <button onClick={accelerate}>Przyspiesz</button>
17    </div>
18  );
19}

Kiedy co stosować?

  • Oddzielne useState - gdy wartości zmieniają się niezależnie od siebie
  • Obiekt w useState - gdy wartości logicznie należą do jednej całości (np. formularz, pozycja x/y)

Najczęstsze błędy - mutacja stanu

W React stan jest niemutowalny (immutable). Nigdy nie modyfikuj stanu bezpośrednio - zawsze twórz nową kopię. To jak zasada bezpieczeństwa na statku kosmicznym: nie modyfikujesz dziennika pokładowego, tylko dopisujesz nowy wpis.

Błąd 1: push zamiast spread dla tablic

1function MissionLog() {
2  const [logs, setLogs] = useState(["Start misji"]);
3
4  const addLog = (entry) => {
5    // BŁĄD: push mutuje istniejącą tablicę!
6    // React nie wykryje zmiany i nie przerenderuje komponentu
7    logs.push(entry);
8    setLogs(logs);
9  };
10
11  const addLogCorrect = (entry) => {
12    // Spread tworzy nową tablicę
13    setLogs(prev => [...prev, entry]);
14  };
15
16  return (
17    <div>
18      <button onClick={() => addLogCorrect("Nowy wpis")}>
19        Dodaj wpis
20      </button>
21      {logs.map((log, i) => <p key={i}>{log}</p>)}
22    </div>
23  );
24}

Błąd 2: bezpośrednia zmiana właściwości obiektu

1function CrewMember() {
2  const [member, setMember] = useState({
3    name: "Kapitan Nova",
4    rank: "Kapitan",
5    missions: 12
6  });
7
8  const promote = () => {
9    // BŁĄD: bezpośrednia mutacja obiektu
10    member.rank = "Admirał";
11    setMember(member);
12  };
13
14  const promoteCorrect = () => {
15    // Tworzenie nowego obiektu z operatorem spread
16    setMember(prev => ({ ...prev, rank: "Admirał" }));
17  };
18
19  return (
20    <div>
21      <p>{member.name} - {member.rank}</p>
22      <button onClick={promoteCorrect}>Awansuj</button>
23    </div>
24  );
25}

Stale closures - pułapka zamknięć

Closure (domknięcie) to sytuacja, gdy funkcja "zapamiętuje" zmienne z momentu jej utworzenia. W React może to prowadzić do odczytu nieaktualnego stanu:

1function CountdownTimer() {
2  const [seconds, setSeconds] = useState(10);
3  const [running, setRunning] = useState(false);
4
5  const startCountdown = () => {
6    // seconds jest "zamrożone" z chwili kliknięcia!
7    // Każdy interwał widzi tę samą wartość
8    setInterval(() => {
9      setSeconds(seconds - 1); // zawsze 10 - 1 = 9
10    }, 1000);
11  };
12
13  const startCountdownCorrect = () => {
14    setRunning(true);
15    // Aktualizacja funkcyjna zawsze używa aktualnej wartości
16    setInterval(() => {
17      setSeconds(prev => Math.max(prev - 1, 0));
18    }, 1000);
19  };
20
21  return (
22    <div>
23      <p>Odliczanie: {seconds}s</p>
24      <button onClick={startCountdownCorrect} disabled={running}>Start</button>
25    </div>
26  );
27}

Przycisk z disabled={running} nie pozwoli uruchomić drugiego interwału, a Math.max(prev - 1, 0) zatrzymuje licznik na zerze. Sam interwał działa jednak dalej, dopóki go nie zatrzymasz przez clearInterval - porządne sprzątanie timerów poznasz w module o efektach (useEffect).

Batching - grupowanie aktualizacji

React 18+ automatycznie grupuje (batchuje) wiele aktualizacji stanu w jednym event handlerze, wykonując tylko jeden re-render. To jak komputer pokładowy, który zbiera wszystkie raporty z czujników i aktualizuje wyświetlacz raz, zamiast odświeżać go po każdym czujniku:

1function NavigationUpdate() {
2  const [position, setPosition] = useState({ x: 0, y: 0 });
3  const [velocity, setVelocity] = useState(0);
4  const [status, setStatus] = useState("idle");
5
6  const handleJump = () => {
7    // React grupuje te 3 aktualizacje w jeden render
8    setPosition({ x: 100, y: 200 });
9    setVelocity(9999);
10    setStatus("hyperjump");
11    // Komponent renderuje się JEDEN raz, nie trzy!
12  };
13
14  return (
15    <div>
16      <p>Pozycja: ({position.x}, {position.y})</p>
17      <p>Prędkość: {velocity} | Status: {status}</p>
18      <button onClick={handleJump}>Skok nadprzestrzenny</button>
19    </div>
20  );
21}

Praktyczne wzorce aktualizacji stanu

Oto kilka wzorców, które przyda ci się znać na pokładzie statku React:

1// Usuwanie elementu z tablicy
2setItems(prev => prev.filter(item => item.id !== idToRemove));
3
4// Aktualizacja jednego elementu w tablicy
5setItems(prev => prev.map(item =>
6  item.id === targetId ? { ...item, status: "done" } : item
7));
8
9// Toggle (przełączanie boolean)
10setIsActive(prev => !prev);
11
12// Dodanie elementu na początek tablicy
13setMessages(prev => [newMessage, ...prev]);
14
15// Inkrementacja z limitem
16setFuel(prev => Math.min(prev + 10, 100));

Leniwa inicjalizacja stanu

Wartość początkowa jest potrzebna tylko przy pierwszym renderze. Jeśli jednak w nawiasie useState wywołasz funkcję, wykona się ona przy każdym renderze, choć React użyje jej wyniku tylko za pierwszym razem:

1// ŹLE - computeStarMap() liczy się przy każdym renderze
2const [stars, setStars] = useState(computeStarMap());

Przy kosztownym obliczeniu (np. wyznaczaniu mapy gwiazd z tysięcy punktów) przekaż samą funkcję - React wywoła ją tylko raz, przy pierwszym renderze:

1// DOBRZE - funkcja inicjalizująca wykona się tylko przy pierwszym renderze
2const [stars, setStars] = useState(() => computeStarMap());

Przekazujesz funkcję (() => computeStarMap() albo samo computeStarMap), a nie jej wynik - React zapamięta wartość i przy kolejnych renderach nie będzie jej już liczył.

Podsumowanie

Aktualizacja stanu w React - kluczowe zasady:

  1. Aktualizacja funkcyjna (prev => prev + 1) - używaj gdy nowa wartość zależy od poprzedniej
  2. Nigdy nie mutuj stanu - zawsze twórz nowe tablice/obiekty (spread operator)
  3. Batching - React grupuje aktualizacje i renderuje komponent raz
  4. Stale closures - w callbackach i timerach używaj aktualizacji funkcyjnej
  5. Wiele useState vs obiekt - zależy od logicznego powiązania danych
  6. Leniwa inicjalizacja - useState(() => obliczenie()) liczy wartość początkową tylko raz
Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3function FuelManager() {
4  // Oddzielne zmienne stanu
5  const [fuel, setFuel] = useState(50);
6  const [oxygen, setOxygen] = useState(100);
7
8  // Stan jako obiekt
9  const [position, setPosition] = useState({ x: 0, y: 0, z: 0 });
10
11  // Stan jako tablica
12  const [logs, setLogs] = useState([
13    { id: 1, message: 'System uruchomiony', time: '00:00' }
14  ]);
15
16  const [newLog, setNewLog] = useState('');
17
18  // Aktualizacja funkcyjna (prev =>)
19  const boostFuel = () => {
20    setFuel(prev => Math.min(prev + 10, 100));
21  };
22
23  const consumeFuel = () => {
24    setFuel(prev => Math.max(prev - 15, 0));
25  };
26
27  // Aktualizacja obiektu z spread
28  const moveShip = (axis, delta) => {
29    setPosition(prev => ({ ...prev, [axis]: prev[axis] + delta }));
30  };
31
32  // Dodawanie do tablicy (spread, nie push!)
33  const addLog = () => {
34    if (!newLog.trim()) return;
35    const now = new Date().toLocaleTimeString().slice(0, 5);
36    setLogs(prev => [...prev, {
37      id: Date.now(),
38      message: newLog,
39      time: now
40    }]);
41    setNewLog('');
42  };
43
44  // Usuwanie z tablicy (filter)
45  const removeLog = (id) => {
46    setLogs(prev => prev.filter(log => log.id !== id));
47  };
48
49  // Triple boost - porównanie błędnego i poprawnego podejścia
50  const brokenTripleBoost = () => {
51    // fuel jest "zamrożone" - wszystkie 3 widzą tę samą wartość
52    setFuel(fuel + 5);
53    setFuel(fuel + 5);
54    setFuel(fuel + 5);
55  };
56
57  const correctTripleBoost = () => {
58    // Każde wywołanie widzi aktualną wartość
59    setFuel(prev => Math.min(prev + 5, 100));
60    setFuel(prev => Math.min(prev + 5, 100));
61    setFuel(prev => Math.min(prev + 5, 100));
62  };
63
64  return (
65    <div style={{ fontFamily: 'Arial, sans-serif', padding: '20px', maxWidth: '700px', margin: '0 auto' }}>
66      <h1 style={{ textAlign: 'center', color: '#667eea' }}>Wzorce aktualizacji stanu</h1>
67
68      {/* Sekcja paliwa */}
69      <div style={{ padding: '16px', marginBottom: '16px', backgroundColor: '#f0f4ff', borderRadius: '12px' }}>
70        <h3>Paliwo: {fuel}%</h3>
71        <div style={{ height: '20px', backgroundColor: '#ddd', borderRadius: '10px', overflow: 'hidden' }}>
72          <div style={{
73            height: '100%', width: fuel + '%',
74            backgroundColor: fuel > 30 ? '#4caf50' : '#f44336',
75            transition: 'width 0.3s'
76          }} />
77        </div>
78        <div style={{ marginTop: '10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
79          <button onClick={boostFuel} style={btnStyle('#4caf50')}>+10 (funkcyjny)</button>
80          <button onClick={consumeFuel} style={btnStyle('#f44336')}>-15 (funkcyjny)</button>
81          <button onClick={brokenTripleBoost} style={btnStyle('#ff9800')}>3x +5 (błędny)</button>
82          <button onClick={correctTripleBoost} style={btnStyle('#2196f3')}>3x +5 (poprawny)</button>
83        </div>
84      </div>
85
86      {/* Sekcja pozycji (obiekt) */}
87      <div style={{ padding: '16px', marginBottom: '16px', backgroundColor: '#f0fff4', borderRadius: '12px' }}>
88        <h3>Pozycja statku (obiekt ze spread)</h3>
89        <p>X: {position.x} | Y: {position.y} | Z: {position.z}</p>
90        <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
91          <button onClick={() => moveShip('x', 10)} style={btnStyle('#667eea')}>X +10</button>
92          <button onClick={() => moveShip('y', 10)} style={btnStyle('#667eea')}>Y +10</button>
93          <button onClick={() => moveShip('z', 10)} style={btnStyle('#667eea')}>Z +10</button>
94          <button onClick={() => setPosition({ x: 0, y: 0, z: 0 })} style={btnStyle('#999')}>Reset</button>
95        </div>
96      </div>
97
98      {/* Sekcja logów (tablica) */}
99      <div style={{ padding: '16px', backgroundColor: '#fff8f0', borderRadius: '12px' }}>
100        <h3>Dziennik pokładowy (tablica ze spread)</h3>
101        <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
102          <input
103            value={newLog}
104            onChange={e => setNewLog(e.target.value)}
105            placeholder="Nowy wpis..."
106            style={{ flex: 1, padding: '8px', borderRadius: '8px', border: '1px solid #ddd' }}
107          />
108          <button onClick={addLog} style={btnStyle('#ff9800')}>Dodaj</button>
109        </div>
110        {logs.map(log => (
111          <div key={log.id} style={{
112            display: 'flex', justifyContent: 'space-between', alignItems: 'center',
113            padding: '8px 12px', marginBottom: '6px', backgroundColor: '#fff',
114            borderRadius: '8px', border: '1px solid #eee'
115          }}>
116            <span><strong>[{log.time}]</strong> {log.message}</span>
117            <button onClick={() => removeLog(log.id)} style={{
118              background: 'none', border: 'none', color: '#f44336', cursor: 'pointer', fontSize: '16px'
119            }}>x</button>
120          </div>
121        ))}
122      </div>
123    </div>
124  );
125}
126
127function btnStyle(bg) {
128  return {
129    padding: '8px 16px', backgroundColor: bg, color: 'white',
130    border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '13px'
131  };
132}
133
134export default function App() {
135  return <FuelManager />;
136}

Pamiętaj: Stan w React to jak dziennik pokładowy twojego statku - nigdy go nie edytujesz, tylko tworzysz nowy wpis na podstawie poprzedniego!

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Dlaczego przy wielokrotnym wywołaniu setState w jednym handlerze należy używać aktualizacji funkcyjnej (prev => prev + 1)?

  2. 2. Dlaczego poniższy kod jest błędny? const [items, setItems] = useState(['A', 'B']); items.push('C'); setItems(items);

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj, co się dzieje, gdy React grupuje (batching) wiele aktualizacji stanu:

Przydatne artykuły