Kurs JavaScript i React · Moduł 3: Stan i zdarzenia
Stan komponentu (State)
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:
- Importujemy hook
useStatez Reacta - Wywołujemy
useState(100), aby stworzyć zmienną stanufuelz wartością początkową 100 - Destrukturyzujemy wynik, aby uzyskać:
fuel: aktualną wartość stanusetFuel: funkcję do aktualizacji tej wartości
- Używamy
setFuelw 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:
- Zmieniają się w czasie
- Wpływają na renderowanie komponentu
- 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: