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

Stan komponentu (State)

7 min czytania
W tej lekcji6

W kosmicznej podróży przez galaktykę React, stan (state) jest jak paliwo rakietowe napędzające interaktywność twoich komponentów. Bez stanu, komponenty byłyby statycznymi obiektami - jak planety bez atmosfery. Stan pozwala komponentom "pamiętać" informacje i reagować na zmiany, czyniąc twoje aplikacje żywymi i responsywnymi.

Czym jest stan komponentu?

Stan to dane (liczba, tekst, wartość logiczna, tablica albo obiekt), które komponent pamięta między kolejnymi renderami. W przeciwieństwie do props, które są przekazywane do komponentu przez rodzica, stan należy do samego komponentu i tylko on może go zmienić - zawsze przez funkcję ustawiającą, np. setFuel, a nie przez bezpośrednią modyfikację.

Wyobraź sobie panel kontrolny statku kosmicznego. Props to jak ustalone współrzędne docelowe - nie możesz ich zmienić z poziomu panelu. Stan natomiast to jak aktualne wskazania prędkości, wysokości, temperatury - dane, które nieustannie się zmieniają i są kontrolowane przez sam panel.

Zarządzanie stanem w komponentach funkcyjnych

W nowoczesnym React, stan w komponentach funkcyjnych zarządzany jest za pomocą hooka useState. Ten prosty, ale potężny hook pozwala na dodanie stanu do komponentów funkcyjnych.

Podstawowa składnia useState

1import React, { useState } from 'react';
2
3function ControlPanel() {
4  // Deklaracja zmiennej stanu "fuel" z wartością początkową 100
5  const [fuel, setFuel] = useState(100);
6
7  // fuel - aktualna wartość stanu
8  // setFuel - funkcja do aktualizacji stanu
9
10  return (
11    <div className="control-panel">
12      <h2>Panel sterowania</h2>
13      <p>Poziom paliwa: {fuel}%</p>
14      <button onClick={() => setFuel(fuel - 10)}>
15        Zużyj paliwo
16      </button>
17    </div>
18  );
19}

W powyższym przykładzie:

  1. Importujemy hook useState z Reacta
  2. Wywołujemy useState(100), aby stworzyć zmienną stanu fuel z wartością początkową 100
  3. Destrukturyzujemy wynik, aby uzyskać:
    • fuel: aktualną wartość stanu
    • setFuel: funkcję do aktualizacji tej wartości
  4. Używamy setFuel w handlerze zdarzenia kliknięcia, aby zmniejszyć wartość paliwa o 10

Używanie wielu zmiennych stanu

Komponent może mieć wiele niezależnych zmiennych stanu:

1function SpaceshipDashboard() {
2  const [fuel, setFuel] = useState(100);
3  const [speed, setSpeed] = useState(0);
4  const [shields, setShields] = useState(true);
5  const [destination, setDestination] = useState('Orbita');
6
7  return (
8    <div className="dashboard">
9      <h2>Pulpit statku kosmicznego</h2>
10      <div className="gauges">
11        <div className="gauge">
12          <h3>Paliwo</h3>
13          <p>{fuel}%</p>
14          <button onClick={() => setFuel(Math.max(0, fuel - 5))}>
15            Zwiększ ciąg
16          </button>
17        </div>
18
19        <div className="gauge">
20          <h3>Prędkość</h3>
21          <p>{speed} km/s</p>
22          <button onClick={() => setSpeed(speed + 1)}>
23            Przyspiesz
24          </button>
25          <button onClick={() => setSpeed(Math.max(0, speed - 1))}>
26            Zwolnij
27          </button>
28        </div>
29
30        <div className="gauge">
31          <h3>Tarcze</h3>
32          <p>{shields ? 'Włączone' : 'Wyłączone'}</p>
33          <button onClick={() => setShields(!shields)}>
34            Przełącz
35          </button>
36        </div>
37
38        <div className="gauge">
39          <h3>Cel podróży</h3>
40          <p>{destination}</p>
41          <select
42            value={destination}
43            onChange={(e) => setDestination(e.target.value)}
44          >
45            <option value="Orbita">Orbita Ziemi</option>
46            <option value="Księżyc">Księżyc</option>
47            <option value="Mars">Mars</option>
48            <option value="Pas asteroid">Pas asteroid</option>
49          </select>
50        </div>
51      </div>
52    </div>
53  );
54}

Złożone struktury stanu

Stan może być prostą wartością (liczba, string, boolean), ale może też być bardziej złożoną strukturą, jak tablica czy obiekt:

1function CrewManagement() {
2  const [crew, setCrew] = useState([
3    { id: 1, name: 'Anna Nowak', role: 'Kapitan', onDuty: true },
4    { id: 2, name: 'Jan Kowalski', role: 'Nawigator', onDuty: false },
5    { id: 3, name: 'Maria Wiśniewska', role: 'Inżynier', onDuty: true }
6  ]);
7
8  const toggleDuty = (crewId) => {
9    setCrew(crew.map(member =>
10      member.id === crewId
11        ? { ...member, onDuty: !member.onDuty }
12        : member
13    ));
14  };
15
16  return (
17    <div className="crew-management">
18      <h2>Zarządzanie załogą</h2>
19      <ul className="crew-list">
20        {crew.map(member => (
21          <li key={member.id} className={member.onDuty ? 'on-duty' : 'off-duty'}>
22            <strong>{member.name}</strong> - {member.role}
23            <button onClick={() => toggleDuty(member.id)}>
24              {member.onDuty ? 'Zakończ służbę' : 'Rozpocznij służbę'}
25            </button>
26          </li>
27        ))}
28      </ul>
29    </div>
30  );
31}

W tym przykładzie stan crew to tablica obiektów. Funkcja toggleDuty używa map do tworzenia nowej tablicy z zaktualizowanym stanem konkretnego członka załogi.

State jako migawka

W React, state działa jak migawka (snapshot). Kiedy React renderuje komponent, "zatrzaskuje" stan w czasie, a wszystkie wartości wewnątrz funkcji renderującej (w tym handlery zdarzeń) będą "widzieć" tę samą migawkę stanu.

Rozważmy przykład:

1function RocketLauncher() {
2  const [countdown, setCountdown] = useState(10);
3
4  const decrementCountdown = () => {
5    // To NIE zadziała jak oczekujemy, jeśli chcemy
6    // wykonać wielokrotne odliczanie w jednym cyklu renderowania
7    setCountdown(countdown - 1); // Używa tej samej wartości ze snapshota
8    setCountdown(countdown - 1); // Wciąż używa tej samej wartości!
9  };
10
11  return (
12    <div className="rocket-launcher">
13      <h2>Odliczanie do startu: {countdown}</h2>
14      <button onClick={decrementCountdown}>
15        Zmniejsz odliczanie
16      </button>
17    </div>
18  );
19}

W powyższym przykładzie, mimo dwukrotnego wywołania setCountdown, wartość zostanie zmniejszona tylko o 1, a nie o 2. Dzieje się tak, ponieważ obie aktualizacje "widzą" tę samą wartość countdown z migawki.

Aktualizacja stanu na podstawie poprzedniego stanu

Aby rozwiązać problem migawek, React pozwala na przekazanie funkcji do funkcji aktualizującej stan. Ta funkcja otrzymuje poprzedni stan jako argument:

1function RocketLauncher() {
2  const [countdown, setCountdown] = useState(10);
3
4  const decrementCountdown = () => {
5    // To zadziała poprawnie, nawet dla wielu aktualizacji
6    setCountdown(prevCount => prevCount - 1); // Bazuje na poprzedniej wartości
7    setCountdown(prevCount => prevCount - 1); // Bazuje na wyniku poprzedniej aktualizacji
8  };
9
10  return (
11    <div className="rocket-launcher">
12      <h2>Odliczanie do startu: {countdown}</h2>
13      <button onClick={decrementCountdown}>
14        Zmniejsz odliczanie
15      </button>
16    </div>
17  );
18}

Teraz, przy każdym kliknięciu przycisku, odliczanie zostanie zmniejszone o 2.

Aktualizacja obiektów w stanie

Przy aktualizacji obiektów w stanie, ważne jest, aby zawsze tworzyć nowy obiekt, a nie modyfikować istniejącego. Jest to zgodne z zasadą niemutowalności (immutability) w React:

1function SpaceshipSystems() {
2  const [systems, setSystems] = useState({
3    engines: 'offline',
4    lifeSupportOutput: 100,
5    navigationStatus: 'calibrating',
6    shields: 85
7  });
8
9  const startEngines = () => {
10    // ŹLE - modyfikacja oryginalnego obiektu
11    // systems.engines = 'online';
12    // setSystems(systems);
13
14    // DOBRZE - tworzenie nowego obiektu
15    setSystems({
16      ...systems, // kopiuje wszystkie istniejące właściwości
17      engines: 'online' // nadpisuje tylko tę, którą chcemy zmienić
18    });
19  };
20
21  return (
22    <div className="spaceship-systems">
23      <h2>Status systemów</h2>
24      <p>Silniki: {systems.engines}</p>
25      <p>System podtrzymywania życia: {systems.lifeSupportOutput}%</p>
26      <p>Nawigacja: {systems.navigationStatus}</p>
27      <p>Tarcze: {systems.shields}%</p>
28
29      <button onClick={startEngines}>
30        Uruchom silniki
31      </button>
32    </div>
33  );
34}

Aktualizacja tablic w stanie

Podobnie jak w przypadku obiektów, przy aktualizacji tablic również należy przestrzegać zasady niemutowalności:

1function MissionLog() {
2  const [logs, setLogs] = useState([
3    { id: 1, time: '08:00', message: 'Rozpoczęcie misji' }
4  ]);
5
6  const addLog = () => {
7    const newLog = {
8      id: Math.max(0, ...logs.map(log => log.id)) + 1, // największe id + 1, więc po usunięciu wpisu się nie powtórzy
9      time: new Date().toLocaleTimeString(),
10      message: 'Nowy wpis w dzienniku'
11    };
12
13    // ŹLE - mutowanie oryginalnej tablicy
14    // logs.push(newLog);
15    // setLogs(logs);
16
17    // DOBRZE - tworzenie nowej tablicy
18    setLogs([...logs, newLog]); // dodawanie na koniec
19    // lub
20    // setLogs([newLog, ...logs]); // dodawanie na początek
21  };
22
23  const removeLog = (logId) => {
24    // Filtrowanie - tworzenie nowej tablicy bez usuwanego elementu
25    setLogs(logs.filter(log => log.id !== logId));
26  };
27
28  return (
29    <div className="mission-log">
30      <h2>Dziennik misji</h2>
31      <button onClick={addLog}>Dodaj nowy wpis</button>
32
33      <ul className="log-entries">
34        {logs.map(log => (
35          <li key={log.id}>
36            <span className="time">{log.time}</span>
37            <span className="message">{log.message}</span>
38            <button onClick={() => removeLog(log.id)}>Usuń</button>
39          </li>
40        ))}
41      </ul>
42    </div>
43  );
44}

Te same operacje na tablicy - dodawanie, przełączanie wpisu i usuwanie - zobaczysz w działaniu w demo poniżej, razem z licznikiem (liczba), przełącznikiem (wartość logiczna) i polem tekstowym (tekst). Zwróć uwagę na ramkę na dole: useState wywołujemy tylko na najwyższym poziomie komponentu, nigdy w pętli, warunku ani funkcji zagnieżdżonej, bo React rozpoznaje kolejne stany po kolejności wywołań przy każdym renderze.

Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3// Prosty licznik - podstawa useState
4function SimpleCounter() {
5  const [count, setCount] = useState(0);
6
7  return (
8    <div style={{
9      backgroundColor: '#252545',
10      padding: '20px',
11      borderRadius: '12px',
12      textAlign: 'center'
13    }}>
14      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Prosty licznik</h3>
15      <div style={{
16        fontSize: '48px',
17        fontWeight: 'bold',
18        margin: '20px 0',
19        color: count > 0 ? '#4caf50' : count < 0 ? '#f44336' : 'white'
20      }}>
21        {count}
22      </div>
23      <div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
24        <button
25          onClick={() => setCount(count - 1)}
26          style={{
27            padding: '10px 20px',
28            backgroundColor: '#f44336',
29            color: 'white',
30            border: 'none',
31            borderRadius: '8px',
32            cursor: 'pointer',
33            fontSize: '18px'
34          }}
35        >
36          -1
37        </button>
38        <button
39          onClick={() => setCount(0)}
40          style={{
41            padding: '10px 20px',
42            backgroundColor: '#666',
43            color: 'white',
44            border: 'none',
45            borderRadius: '8px',
46            cursor: 'pointer'
47          }}
48        >
49          Reset
50        </button>
51        <button
52          onClick={() => setCount(count + 1)}
53          style={{
54            padding: '10px 20px',
55            backgroundColor: '#4caf50',
56            color: 'white',
57            border: 'none',
58            borderRadius: '8px',
59            cursor: 'pointer',
60            fontSize: '18px'
61          }}
62        >
63          +1
64        </button>
65      </div>
66    </div>
67  );
68}
69
70// Przełącznik - stan logiczny (boolean)
71function ToggleSwitch() {
72  const [isOn, setIsOn] = useState(false);
73
74  return (
75    <div style={{
76      backgroundColor: '#252545',
77      padding: '20px',
78      borderRadius: '12px',
79      textAlign: 'center'
80    }}>
81      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Przełącznik</h3>
82      <div
83        onClick={() => setIsOn(!isOn)}
84        style={{
85          width: '80px',
86          height: '40px',
87          backgroundColor: isOn ? '#4caf50' : '#666',
88          borderRadius: '20px',
89          cursor: 'pointer',
90          position: 'relative',
91          margin: '20px auto',
92          transition: 'background-color 0.3s'
93        }}
94      >
95        <div style={{
96          width: '32px',
97          height: '32px',
98          backgroundColor: 'white',
99          borderRadius: '50%',
100          position: 'absolute',
101          top: '4px',
102          left: isOn ? '44px' : '4px',
103          transition: 'left 0.3s'
104        }} />
105      </div>
106      <p style={{ color: '#ccc' }}>
107        Status: <strong style={{ color: isOn ? '#4caf50' : '#f44336' }}>
108          {isOn ? 'WŁĄCZONY' : 'WYŁĄCZONY'}
109        </strong>
110      </p>
111    </div>
112  );
113}
114
115// Pole tekstowe - stan tekstowy (string)
116function TextInput() {
117  const [text, setText] = useState('');
118
119  return (
120    <div style={{
121      backgroundColor: '#252545',
122      padding: '20px',
123      borderRadius: '12px'
124    }}>
125      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Pole tekstowe</h3>
126      <input
127        type="text"
128        value={text}
129        onChange={(e) => setText(e.target.value)}
130        placeholder="Wpisz coś..."
131        style={{
132          width: '100%',
133          padding: '12px 15px',
134          border: '2px solid #444',
135          borderRadius: '8px',
136          backgroundColor: '#1a1a2e',
137          color: 'white',
138          fontSize: '16px',
139          boxSizing: 'border-box'
140        }}
141      />
142      <p style={{ color: '#888', marginBottom: 0 }}>
143        Wpisano: <strong style={{ color: 'white' }}>{text || '(pusto)'}</strong>
144        {' '}({text.length} znaków)
145      </p>
146    </div>
147  );
148}
149
150// Lista - stan tablicowy (array)
151function TodoList() {
152  const [todos, setTodos] = useState([
153    { id: 1, text: 'Nauczyć się React', done: true },
154    { id: 2, text: 'Zrozumieć useState', done: false },
155    { id: 3, text: 'Stworzyć projekt', done: false }
156  ]);
157  const [newTodo, setNewTodo] = useState('');
158
159  const addTodo = () => {
160    if (newTodo.trim()) {
161      setTodos([...todos, {
162        id: Date.now(),
163        text: newTodo,
164        done: false
165      }]);
166      setNewTodo('');
167    }
168  };
169
170  const toggleTodo = (id) => {
171    setTodos(todos.map(todo =>
172      todo.id === id ? { ...todo, done: !todo.done } : todo
173    ));
174  };
175
176  const deleteTodo = (id) => {
177    setTodos(todos.filter(todo => todo.id !== id));
178  };
179
180  return (
181    <div style={{
182      backgroundColor: '#252545',
183      padding: '20px',
184      borderRadius: '12px'
185    }}>
186      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Lista zadań</h3>
187
188      <div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
189        <input
190          type="text"
191          value={newTodo}
192          onChange={(e) => setNewTodo(e.target.value)}
193          onKeyDown={(e) => e.key === 'Enter' && addTodo()}
194          placeholder="Nowe zadanie..."
195          style={{
196            flex: 1,
197            padding: '10px 15px',
198            border: '2px solid #444',
199            borderRadius: '8px',
200            backgroundColor: '#1a1a2e',
201            color: 'white'
202          }}
203        />
204        <button
205          onClick={addTodo}
206          style={{
207            padding: '10px 20px',
208            backgroundColor: '#4caf50',
209            color: 'white',
210            border: 'none',
211            borderRadius: '8px',
212            cursor: 'pointer'
213          }}
214        >
215          Dodaj
216        </button>
217      </div>
218
219      <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
220        {todos.map(todo => (
221          <div key={todo.id} style={{
222            display: 'flex',
223            alignItems: 'center',
224            gap: '10px',
225            padding: '10px 15px',
226            backgroundColor: '#1a1a2e',
227            borderRadius: '8px'
228          }}>
229            <input
230              type="checkbox"
231              checked={todo.done}
232              onChange={() => toggleTodo(todo.id)}
233              style={{ width: '20px', height: '20px', cursor: 'pointer' }}
234            />
235            <span style={{
236              flex: 1,
237              textDecoration: todo.done ? 'line-through' : 'none',
238              color: todo.done ? '#666' : 'white'
239            }}>
240              {todo.text}
241            </span>
242            <button
243              onClick={() => deleteTodo(todo.id)}
244              style={{
245                padding: '5px 10px',
246                backgroundColor: '#f44336',
247                color: 'white',
248                border: 'none',
249                borderRadius: '4px',
250                cursor: 'pointer'
251              }}
252            >
253              ×
254            </button>
255          </div>
256        ))}
257      </div>
258      <p style={{ color: '#888', marginBottom: 0, marginTop: '10px' }}>
259        Ukończono: {todos.filter(t => t.done).length} / {todos.length}
260      </p>
261    </div>
262  );
263}
264
265export default function App() {
266  return (
267    <div style={{
268      fontFamily: 'Arial, sans-serif',
269      backgroundColor: '#0a0a1a',
270      minHeight: '100vh',
271      padding: '20px',
272      color: 'white'
273    }}>
274      <header style={{
275        textAlign: 'center',
276        marginBottom: '30px',
277        padding: '30px',
278        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
279        borderRadius: '16px'
280      }}>
281        <h1 style={{ margin: 0 }}>useState w React</h1>
282        <p style={{ margin: '10px 0 0 0', opacity: 0.9 }}>
283          Zarządzanie stanem komponentów
284        </p>
285      </header>
286
287      <div style={{
288        display: 'grid',
289        gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
290        gap: '20px'
291      }}>
292        <SimpleCounter />
293        <ToggleSwitch />
294        <TextInput />
295        <TodoList />
296      </div>
297
298      <div style={{
299        backgroundColor: '#1a1a2e',
300        padding: '20px',
301        borderRadius: '12px',
302        marginTop: '30px'
303      }}>
304        <h3 style={{ color: '#ff9800', marginTop: 0 }}>Zasady useState:</h3>
305        <ul style={{ color: '#ccc', lineHeight: '1.8', paddingLeft: '20px' }}>
306          <li>Wywoływać tylko na <strong>górnym poziomie</strong> komponentu</li>
307          <li>Nie używać w pętlach, warunkach czy funkcjach zagnieżdżonych</li>
308          <li>Aktualizacja stanu powoduje <strong>ponowne renderowanie</strong></li>
309          <li>Stan jest <strong>izolowany</strong> dla każdej instancji komponentu</li>
310        </ul>
311      </div>
312    </div>
313  );
314}

Każdy z czterech paneli ma własny, niezależny stan: kliknięcie licznika nie zmienia listy zadań, bo każdy komponent przechowuje swój stan osobno.

Kiedy używać state?

Stan powinien być używany dla danych, które:

  1. Zmieniają się w czasie
  2. Wpływają na renderowanie komponentu
  3. Nie mogą być obliczone na podstawie props lub innego stanu

Nie wszystkie dane powinny być w stanie. Dobre pytania, które warto sobie zadać:

  • Czy dane pochodzą od rodzica poprzez props? Jeśli tak, prawdopodobnie nie powinny być w stanie.
  • Czy dane pozostają niezmienione w czasie? Jeśli tak, prawdopodobnie nie powinny być w stanie.
  • Czy dane można obliczyć na podstawie istniejących props lub stanu? Jeśli tak, zdecydowanie nie powinny być w stanie.

Podsumowanie

Stan (state) jest kluczowym elementem komponentów React, który pozwala na:

  • Przechowywanie danych specyficznych dla komponentu
  • Zarządzanie danymi, które zmieniają się w czasie
  • Tworzenie interaktywnych interfejsów użytkownika, które reagują na akcje użytkownika

W następnych sekcjach zobaczymy, jak obsługiwać zdarzenia w React i jak łączyć je z zarządzaniem stanem, aby tworzyć w pełni interaktywne aplikacje.

Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3function ControlPanel() {
4  const [fuel, setFuel] = useState(100);
5  const [speed, setSpeed] = useState(0);
6  const [shields, setShields] = useState(true);
7
8  return (
9    <div className="control-panel">
10      <h1>Panel sterowania statkiem kosmicznym</h1>
11
12      <div className="gauges">
13        <div className="gauge">
14          <h3>Paliwo</h3>
15          <div className="progress-bar">
16            <div
17              className="progress-fill fuel"
18              style={{ width: `${fuel}%` }}
19            >
20              {fuel}%
21            </div>
22          </div>
23          <button
24            onClick={() => setFuel(Math.max(0, fuel - 10))}
25            disabled={fuel <= 0}
26          >
27            Zwiększ ciąg (-10% paliwa)
28          </button>
29        </div>
30
31        <div className="gauge">
32          <h3>Prędkość</h3>
33          <p className="speed-display">{speed} km/s</p>
34          <div className="speed-controls">
35            <button onClick={() => setSpeed(speed + 1)}>
36              Przyspiesz (+1)
37            </button>
38            <button
39              onClick={() => setSpeed(Math.max(0, speed - 1))}
40              disabled={speed <= 0}
41            >
42              Zwolnij (-1)
43            </button>
44          </div>
45        </div>
46
47        <div className="gauge">
48          <h3>Tarcze</h3>
49          <p className={`shield-status ${shields ? 'active' : 'inactive'}`}>
50            {shields ? 'Włączone' : 'Wyłączone'}
51          </p>
52          <button onClick={() => setShields(!shields)}>
53            {shields ? 'Wyłącz tarcze' : 'Włącz tarcze'}
54          </button>
55        </div>
56      </div>
57    </div>
58  );
59}
60
61function App() {
62  return (
63    <div className="app">
64      <ControlPanel />
65    </div>
66  );
67}
68
69export default App;

Widzisz błąd w tej lekcji?

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż składnię deklaracji stanu za pomocą useState:

Przydatne artykuły