Temat 35 · 4 × 45 min (2 lekcje)
React – komponenty i stan
Przez trzydzieści lekcji budowałeś interfejsy „ręcznie”: getElementById, createElement, innerHTML, osobna zmienna na stan i funkcja, która po każdej zmianie poprawia DOM. W małym kalkulatorze to działa. W aplikacji z listą, filtrem, formularzem i licznikiem robi się z tego sieć zależności, w której łatwo zapomnieć o jednym miejscu do odświeżenia. React rozwiązuje ten problem jedną zasadą: opisujesz, jak interfejs ma wyglądać dla danego stanu, a aktualizację DOM zostawiasz bibliotece. Ta lekcja (dwie godziny lekcyjne) przeprowadza cię od pierwszego komponentu do małej aplikacji z formularzem i danymi z serwera.
Po co framework, skoro umiesz czysty JS?
Porównaj dwa liczniki. Pierwszy znasz z lekcji 17: stan w zmiennej, funkcja aktualizująca DOM, nasłuchiwacz.
let liczba = 0;
const przycisk = document.getElementById('licznik');
function odswiez() {
przycisk.textContent = `Kliknięto: ${liczba}`; // ręcznie poprawiasz DOM
}
przycisk.addEventListener('click', () => {
liczba++;
odswiez(); // o tym wywołaniu łatwo zapomnieć, gdy miejsc do odświeżenia jest 5
});
odswiez();Ten sam licznik w React:
function Licznik() {
const [liczba, setLiczba] = useState(0);
return (
<button onClick={() => setLiczba(liczba + 1)}>
Kliknięto: {liczba}
</button>
);
}Nie ma odswiez(). Komponent to funkcja, która zwraca opis interfejsu dla aktualnego stanu. Gdy wywołasz setLiczba, React wywoła Licznik() jeszcze raz, porówna nowy opis ze starym i zmieni w DOM tylko to, co się różni (tekst przycisku). Przy jednym przycisku zysk jest mały. Przy liście zadań z filtrem, licznikiem „pozostało: 3” i formularzem – ogromny, bo każdy z tych fragmentów po prostu czyta stan, a ty nigdy nie piszesz kodu „znajdź element i popraw mu tekst”.
React nie jest jedyny. Angular (Google) to pełny framework pisany w TypeScript, z własnym systemem modułów i szablonów HTML – to on obok React pojawia się w informatorze INF.04. Vue i Svelte są lżejsze i popularne w mniejszych firmach. Wszystkie opierają się na tym samym pomyśle: komponenty + stan → automatyczna aktualizacja widoku. Kto rozumie React, w tydzień czyta kod w Vue.
Jak uruchomić React
W prawdziwym projekcie: Vite
React to dwie paczki npm (react – logika komponentów, react-dom – łączenie z DOM przeglądarki) plus kompilator, który zamienia JSX na zwykłe wywołania funkcji. Wszystko to dostaje gotowe od Vite, który znasz z lekcji 32. Potrzebujesz Node.js (co najmniej 20.19 lub 22.12; w szkole najpewniej masz 22 lub 24 LTS).
npm create vite@latest lista-zadan -- --template react
cd lista-zadan
npm install
npm run devPodwójny myślnik -- przed --template jest potrzebny w npm (przekazuje opcję do skryptu, a nie do npm). Dostępne szablony dla React to react (JavaScript), react-ts (TypeScript – użyjesz go po lekcji 36) oraz warianty react-compiler i react-compiler-ts. Po npm run dev strona działa pod http://localhost:5173 i odświeża się sama po każdym zapisie pliku.
Struktura wygenerowanego projektu (wersja React 19):
index.html ← jedyny plik HTML, zawiera <div id="root"></div>
package.json ← zależności: react, react-dom; skrypty dev/build/lint/preview
vite.config.js
.oxlintrc.json ← konfiguracja lintera (szablon używa szybkiego oxlint zamiast ESLint)
public/ ← pliki statyczne (ikony, obrazki)
src/
main.jsx ← start: createRoot(...).render(<App />)
App.jsx ← pierwszy komponent – tu będziesz pisać
App.css
index.cssimport { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);createRoot wskazuje element HTML, w którym React „mieszka”, a render montuje w nim komponent App. StrictMode włącza dodatkowe ostrzeżenia podczas rozwoju i nie trafia do wersji produkcyjnej. Resztą (plik App.jsx, kolejne komponenty w src/) zarządzasz ty.
Na tej stronie: plac zabaw bez instalacji
Przykłady poniżej uruchamiasz od razu w przeglądarce. Ponieważ plac zabaw nie ma Node ani Vite, biblioteki przychodzą z CDN jako moduły ES (esm.sh), a JSX kompiluje na żywo Babel standalone. Każdy przykład zaczyna się od tych samych linii:
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
// ...tu komponenty...
createRoot(document.getElementById('root')).render(<App />);
</script>Mapa importów (importmap) tłumaczy 'react' na adres CDN, tak jak w Vite robi to node_modules. Skrypt type="text/babel" nie jest wykonywany przez przeglądarkę – Babel go odczytuje, zamienia JSX na JS i wstawia z powrotem jako type="module". To wygodne do nauki i absolutnie nie do projektów: kompilacja w przeglądarce jest wolna (w konsoli zobaczysz ostrzeżenie Babela „precompile your scripts for production”), a React 19 nie ma już wersji UMD do zwykłego <script src>. Prawdziwy projekt = Vite.
JSX – HTML w JavaScripcie
JSX wygląda jak HTML, ale jest wyrażeniem JavaScript: możesz je przypisać do zmiennej, zwrócić z funkcji, wstawić do tablicy. Kompilator zamienia <h1>Cześć</h1> na wywołanie funkcji tworzącej obiekt opisujący element. Z tego wynika kilka różnic względem HTML:
| HTML | JSX | Dlaczego |
|---|---|---|
class="karta" | className="karta" | class to słowo kluczowe JS |
for="imie" | htmlFor="imie" | for to słowo kluczowe JS |
onclick="f()" | onClick={f} | camelCase; przekazujesz funkcję, nie napis |
<input> | <input /> | każdy tag musi być zamknięty |
style="color:red" | style={{ color: 'red' }} | obiekt JS, nazwy camelCase (fontSize) |
<!-- komentarz --> | {/* komentarz */} | komentarz JS wewnątrz nawiasów klamrowych |
| dwa elementy obok siebie | <>...</> (fragment) | funkcja zwraca jedną wartość |
Nawiasy klamrowe { } to „okno” z JSX do JavaScriptu – wstawisz w nie każde wyrażenie: zmienną, wywołanie funkcji, operator warunkowy. Nie wstawisz instrukcji (if, for) – zamiast nich używasz ? :, && i metod tablic.
Co wyświetli ten komponent przy ocena = 2?
function Wynik({ ocena }) {
return (
<p>
Ocena: {ocena} {ocena >= 2 ? 'zdane' : 'niezdane'}
{ocena === 6 && ' – celująco!'}
</p>
);
}<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React from 'react';
import { createRoot } from 'react-dom/client';
const uczen = { imie: 'Ola', oceny: [5, 4, 6, 3] };
const srednia = uczen.oceny.reduce((suma, o) => suma + o, 0) / uczen.oceny.length;
function App() {
const zagrozona = srednia < 2;
return (
<>
{/* komentarz w JSX */}
<h2 className="naglowek">Karta ucznia: {uczen.imie}</h2>
<p style={{ color: zagrozona ? 'crimson' : 'green', fontWeight: 'bold' }}>
Średnia: {srednia.toFixed(2)} {srednia >= 4.75 && '– wyróżnienie'}
</p>
<p>{uczen.oceny.length > 0 ? `Ocen: ${uczen.oceny.length}` : 'Brak ocen'}</p>
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Zmień oceny na pustą tablicę i uruchom ponownie – zobaczysz „Brak ocen” i średnią NaN. Popraw to warunkiem; to pierwsze „Modify” tej lekcji.
Komponenty i props
Komponent to funkcja, której nazwa zaczyna się wielką literą i która zwraca JSX. Używasz go jak tagu: <KartaProduktu />. Dane przekazujesz atrybutami – React zbiera je w jeden obiekt props (właściwości) i podaje jako pierwszy argument funkcji. Najczęściej od razu go destrukturyzujesz (lekcja 21).
function KartaProduktu({ nazwa, cena, dostepny = true }) {
return (
<article className="karta">
<h3>{nazwa}</h3>
<p>{cena.toFixed(2)} zł</p>
{!dostepny && <p className="brak">Chwilowo niedostępny</p>}
</article>
);
}
// użycie – atrybuty stają się polami props
<KartaProduktu nazwa="Słuchawki" cena={199} />
<KartaProduktu nazwa="Kabel USB-C" cena={24.9} dostepny={false} />Napisy podajesz w cudzysłowie, wszystko inne (liczby, obiekty, tablice, funkcje, wartości logiczne) w nawiasach klamrowych. props są tylko do odczytu – komponent nie zmienia tego, co dostał od rodzica. Jeśli potrzebuje coś zmienić, prosi rodzica funkcją (o tym za chwilę).
Specjalny props children zawiera to, co wpiszesz między tagiem otwierającym a zamykającym – przydaje się do „ramek”, „kart” i „modali”:
function Ramka({ tytul, children }) {
return (
<section className="ramka">
<h2>{tytul}</h2>
{children}
</section>
);
}
<Ramka tytul="Uwaga">
<p>Ten tekst trafi do children.</p>
</Ramka><div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React from 'react';
import { createRoot } from 'react-dom/client';
function KartaProduktu({ nazwa, cena, dostepny = true }) {
return (
<article style={{ border: '1px solid #ccc', borderRadius: 8, padding: 10, marginBottom: 8 }}>
<strong>{nazwa}</strong> – {cena.toFixed(2)} zł
{!dostepny && <span style={{ color: 'crimson' }}> (niedostępny)</span>}
</article>
);
}
function Ramka({ tytul, children }) {
return (
<section>
<h2 style={{ margin: '0 0 8px' }}>{tytul}</h2>
{children}
</section>
);
}
function App() {
return (
<Ramka tytul="Promocje tygodnia">
<KartaProduktu nazwa="Słuchawki" cena={199} />
<KartaProduktu nazwa="Kabel USB-C" cena={24.9} dostepny={false} />
<KartaProduktu nazwa="Podkładka" cena={15} />
</Ramka>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Stan: useState
Zwykła zmienna w komponencie nie nadaje się na stan: komponent to funkcja, więc przy każdym renderze wszystkie jej let startują od nowa. Potrzebujesz pamięci, która przeżywa kolejne wywołania i powiadamia React o zmianie – to robi hook useState.
const [liczba, setLiczba] = useState(0);
// ^ aktualna wartość ^ funkcja zmieniająca ^ wartość początkowauseState zwraca dwuelementową tablicę (stąd destrukturyzacja): aktualną wartość oraz funkcję, którą jedynie wolno tę wartość zmieniać. Wywołanie setLiczba(5) nie zmienia liczba od razu – zleca Reactowi ponowny render, a w tym nowym renderze liczba ma już 5. Hooki (funkcje use…) wywołujesz zawsze na górze komponentu, nigdy w if, pętli ani w funkcji zagnieżdżonej – React identyfikuje je po kolejności wywołań.
Licznik startuje od 0. Co pokaże po jednym kliknięciu?
function Licznik() {
const [liczba, setLiczba] = useState(0);
function klik() {
setLiczba(liczba + 1);
setLiczba(liczba + 1);
console.log(liczba);
}
return <button onClick={klik}>{liczba}</button>;
}<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
function Licznik() {
const [liczba, setLiczba] = useState(0);
console.log('render Licznik, liczba =', liczba); // zobacz w konsoli, kiedy React wywołuje funkcję
function dodajDwa() {
setLiczba((poprzednia) => poprzednia + 1); // forma funkcyjna – bezpieczna przy kilku zmianach
setLiczba((poprzednia) => poprzednia + 1);
}
return (
<p>
<button onClick={() => setLiczba(liczba + 1)}>+1</button>{' '}
<button onClick={dodajDwa}>+2</button>{' '}
<button onClick={() => setLiczba(0)}>Reset</button>{' '}
Kliknięto: <strong>{liczba}</strong>
</p>
);
}
function Szczegoly() {
const [widoczne, setWidoczne] = useState(false);
return (
<div>
<button onClick={() => setWidoczne(!widoczne)}>
{widoczne ? 'Ukryj' : 'Pokaż'} szczegóły
</button>
{widoczne && <p>Egzamin INF.03: część praktyczna trwa 150 minut, próg 75%.</p>}
</div>
);
}
function App() {
return (
<>
<Licznik />
<Szczegoly />
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Zwróć uwagę na Szczegoly: w czystym JS robiłeś element.style.display = 'none'. W React element po prostu nie istnieje w opisie UI, gdy widoczne jest false – React sam usunie go z DOM i doda z powrotem.
Stan to nie tylko liczby – obiekty i tablice bez mutacji
React sprawdza, czy stan się zmienił, porównując referencje (lekcja 24). zadania.push(nowe) zmienia tablicę w miejscu – referencja ta sama, więc React nie zauważy zmiany i nie odświeży widoku. Zawsze tworzysz nową tablicę lub obiekt:
// dodanie
setZadania([...zadania, nowe]);
// usunięcie
setZadania(zadania.filter((z) => z.id !== id));
// zmiana jednego elementu
setZadania(zadania.map((z) => (z.id === id ? { ...z, zrobione: !z.zrobione } : z)));
// zmiana pola w obiekcie
setUczen({ ...uczen, klasa: '4TP' });To dokładnie metody z lekcji 22: map, filter i spread zwracają nowe wartości, a push, splice i przypisanie obiekt.pole = x mutują stare.
Zdarzenia
Zdarzenia w React wyglądają jak atrybuty onclick z dialektu egzaminacyjnego, ale działają jak addEventListener: do onClick, onChange, onSubmit, onKeyDown przekazujesz funkcję, a React sam ją wywoła i poda obiekt zdarzenia (event.target, event.key, event.preventDefault() – te same, które znasz z lekcji 12 i 27).
<button onClick={zapisz}>Zapisz</button> {/* referencja do funkcji */}
<button onClick={() => usun(zadanie.id)}>Usuń</button> {/* z argumentem: owijasz w arrow */}
<input onKeyDown={(e) => e.key === 'Enter' && dodaj()} /> {/* obiekt zdarzenia */}Listy: map i key
Tablicę danych zamieniasz na tablicę elementów JSX metodą map – React wyrenderuje je po kolei. Każdy element listy musi mieć atrybut key: stabilny, unikalny identyfikator (id z bazy, Date.now() przy tworzeniu, nigdy losowa liczba w trakcie renderu). Dzięki key React wie, który element został usunięty, a który tylko się przesunął, i nie gubi np. tekstu wpisanego w pole formularza w wierszu listy.
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
const START = [
{ id: 1, tekst: 'Powtórzyć pętle', zrobione: true },
{ id: 2, tekst: 'Zrobić arkusz 01.2025', zrobione: false },
{ id: 3, tekst: 'Przeczytać o useState', zrobione: false },
];
function App() {
const [zadania, setZadania] = useState(START);
function przelacz(id) {
setZadania(zadania.map((z) => (z.id === id ? { ...z, zrobione: !z.zrobione } : z)));
}
function usun(id) {
setZadania(zadania.filter((z) => z.id !== id));
}
const pozostalo = zadania.filter((z) => !z.zrobione).length;
return (
<>
<p>Pozostało: <strong>{pozostalo}</strong> z {zadania.length}</p>
<ul>
{zadania.map((z) => (
<li key={z.id} style={{ textDecoration: z.zrobione ? 'line-through' : 'none' }}>
<input type="checkbox" checked={z.zrobione} onChange={() => przelacz(z.id)} />{' '}
{z.tekst}{' '}
<button onClick={() => usun(z.id)}>Usuń</button>
</li>
))}
</ul>
{zadania.length === 0 && <p>Wszystko zrobione.</p>}
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>„Pozostało: 1” liczy się samo ze stanu – nie ma funkcji odswiezLicznik(). Porównaj z listą zadań z lekcji 16, gdzie każdą taką zmianę pisałeś ręcznie.
Formularze kontrolowane
W czystym JS pole formularza samo pamięta, co użytkownik wpisał, a ty odczytujesz input.value po kliknięciu (lekcja 13). W React odwracasz kierunek: stan jest źródłem prawdy, pole tylko go pokazuje. Takie pole nazywa się kontrolowanym (controlled): ma value={stan} i onChange, który przy każdym znaku zapisuje nową wartość do stanu.
const [imie, setImie] = useState('');
<input value={imie} onChange={(e) => setImie(e.target.value)} />Dzięki temu w każdej chwili wiesz, co jest w polu (bez getElementById), możesz walidować na żywo, czyścić pole po wysłaniu (setImie('')) albo wyłączyć przycisk, gdy pole puste. Dla checkbox zamiast value używasz checked, dla select – value na elemencie select.
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
function App() {
const [tekst, setTekst] = useState('');
const [priorytet, setPriorytet] = useState('normalny');
const [pilne, setPilne] = useState(false);
const [zadania, setZadania] = useState([]);
const poprawne = tekst.trim().length >= 3;
function dodaj(e) {
e.preventDefault(); // formularz nie przeładuje strony
if (!poprawne) return;
setZadania([...zadania, { id: Date.now(), tekst: tekst.trim(), priorytet, pilne }]);
setTekst(''); // czyścisz pole przez stan, nie przez input.value = ''
setPilne(false);
}
return (
<>
<form onSubmit={dodaj}>
<input
value={tekst}
onChange={(e) => setTekst(e.target.value)}
placeholder="Co masz zrobić? (min. 3 znaki)"
/>{' '}
<select value={priorytet} onChange={(e) => setPriorytet(e.target.value)}>
<option value="niski">niski</option>
<option value="normalny">normalny</option>
<option value="wysoki">wysoki</option>
</select>{' '}
<label>
<input type="checkbox" checked={pilne} onChange={(e) => setPilne(e.target.checked)} /> pilne
</label>{' '}
<button type="submit" disabled={!poprawne}>Dodaj</button>
</form>
{tekst.length > 0 && !poprawne && <p style={{ color: 'crimson' }}>Za krótki opis.</p>}
<ul>
{zadania.map((z) => (
<li key={z.id}>
{z.pilne && '!!! '}{z.tekst} <small>({z.priorytet})</small>
</li>
))}
</ul>
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Podnoszenie stanu (lifting state up)
Co zrobić, gdy dwa komponenty potrzebują tych samych danych – np. Formularz dodaje zadania, a Lista je wyświetla? Stan przenosisz do najbliższego wspólnego rodzica i przekazujesz w dół: dane jako props, a możliwość zmiany jako funkcję w props. Dane płyną w dół, zdarzenia w górę – to jednokierunkowy przepływ danych, podstawowa zasada React.
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
// Dziecko 1: ma własny, lokalny stan pola, ale gotowe zadanie oddaje rodzicowi
function Formularz({ onDodaj }) {
const [tekst, setTekst] = useState('');
function wyslij(e) {
e.preventDefault();
if (tekst.trim() === '') return;
onDodaj(tekst.trim()); // zdarzenie "w górę"
setTekst('');
}
return (
<form onSubmit={wyslij}>
<input value={tekst} onChange={(e) => setTekst(e.target.value)} placeholder="Nowe zadanie" />{' '}
<button>Dodaj</button>
</form>
);
}
// Dziecko 2: nie ma stanu – tylko wyświetla to, co dostało
function Lista({ zadania, onPrzelacz, onUsun }) {
if (zadania.length === 0) return <p>Brak zadań w tym widoku.</p>;
return (
<ul>
{zadania.map((z) => (
<li key={z.id}>
<label style={{ textDecoration: z.zrobione ? 'line-through' : 'none' }}>
<input type="checkbox" checked={z.zrobione} onChange={() => onPrzelacz(z.id)} /> {z.tekst}
</label>{' '}
<button onClick={() => onUsun(z.id)}>Usuń</button>
</li>
))}
</ul>
);
}
// Dziecko 3: przyciski filtra – też bez własnego stanu
function Filtr({ aktywny, onZmien }) {
return ['wszystkie', 'aktywne', 'zrobione'].map((f) => (
<button key={f} onClick={() => onZmien(f)} disabled={f === aktywny} style={{ marginRight: 6 }}>
{f}
</button>
));
}
// Rodzic: jedyny właściciel danych
function App() {
const [zadania, setZadania] = useState([
{ id: 1, tekst: 'Zrobić arkusz 01.2025', zrobione: false },
{ id: 2, tekst: 'Powtórzyć pętle', zrobione: true },
]);
const [filtr, setFiltr] = useState('wszystkie');
const dodaj = (tekst) => setZadania([...zadania, { id: Date.now(), tekst, zrobione: false }]);
const przelacz = (id) => setZadania(zadania.map((z) => (z.id === id ? { ...z, zrobione: !z.zrobione } : z)));
const usun = (id) => setZadania(zadania.filter((z) => z.id !== id));
const widoczne = zadania.filter((z) =>
filtr === 'wszystkie' ? true : filtr === 'aktywne' ? !z.zrobione : z.zrobione
);
return (
<>
<Formularz onDodaj={dodaj} />
<p><Filtr aktywny={filtr} onZmien={setFiltr} /></p>
<Lista zadania={widoczne} onPrzelacz={przelacz} onUsun={usun} />
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Zauważ konwencję nazw: props z funkcją nazywasz onCoś (onDodaj, onUsun), a funkcję w rodzicu – czasownikiem (dodaj, usun). Lista i Filtr to komponenty „głupie” (prezentacyjne): nie mają stanu, więc łatwo je testować i używać w wielu miejscach.
useEffect i fetch – dane z serwera
Wszystko, co dotąd robiłeś, działo się „w środku” renderu. Pobranie danych z API to efekt uboczny: nie należy do opisu UI i nie może blokować renderu. Do takich zadań służy hook useEffect(funkcja, zaleznosci): React wywołuje funkcja po wyrenderowaniu komponentu, a tablica zaleznosci mówi, kiedy ją powtórzyć:
[]– tylko raz, po pierwszym renderze (typowe dla „pobierz dane na start”),[id]– po pierwszym renderze i za każdym razem, gdy zmieni sięid,- brak tablicy – po każdym renderze (prawie nigdy tego nie chcesz).
Typowy wzorzec z fetch z lekcji 30: trzy kawałki stanu (dane, ładowanie, błąd) i funkcja async zdefiniowana wewnątrz efektu (sama funkcja efektu nie może być async, bo musi zwracać undefined albo funkcję czyszczącą).
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
function ZadaniaZApi({ uzytkownik }) {
const [zadania, setZadania] = useState([]);
const [laduje, setLaduje] = useState(true);
const [blad, setBlad] = useState(null);
useEffect(() => {
let aktualne = true; // chroni przed odpowiedzią, która przyszła po zmianie użytkownika
async function pobierz() {
setLaduje(true);
setBlad(null);
try {
const odp = await fetch(`https://jsonplaceholder.typicode.com/todos?userId=${uzytkownik}&_limit=5`);
if (!odp.ok) throw new Error(`HTTP ${odp.status}`);
const dane = await odp.json();
if (aktualne) setZadania(dane);
} catch (e) {
if (aktualne) setBlad(e.message);
} finally {
if (aktualne) setLaduje(false);
}
}
pobierz();
return () => { aktualne = false; }; // funkcja czyszcząca – React wywoła ją przed kolejnym efektem
}, [uzytkownik]); // powtórz, gdy zmieni się użytkownik
if (laduje) return <p>Ładowanie…</p>;
if (blad) return <p style={{ color: 'crimson' }}>Błąd: {blad}</p>;
return (
<ul>
{zadania.map((z) => (
<li key={z.id}>{z.completed ? '[x]' : '[ ]'} {z.title}</li>
))}
</ul>
);
}
function App() {
const [uzytkownik, setUzytkownik] = useState(1);
return (
<>
<label>
Użytkownik:{' '}
<select value={uzytkownik} onChange={(e) => setUzytkownik(Number(e.target.value))}>
{[1, 2, 3].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<ZadaniaZApi uzytkownik={uzytkownik} />
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Przykład korzysta z publicznego, testowego API JSONPlaceholder – wymaga połączenia z internetem. Zmień _limit=5 na _limit=20 albo wpisz zły adres (/todoss) i zobacz, jak zachowują się stany ładowania i błędu.
Vanilla kontra React – ta sama lista zadań
| Zadanie | Czysty JS (lekcje 16–17) | React |
|---|---|---|
| gdzie jest stan | zmienna globalna let zadania = [] | const [zadania, setZadania] = useState([]) |
| dodanie elementu | zadania.push(z) + createElement + appendChild | setZadania([...zadania, z]) – widok sam się odświeży |
| licznik „pozostało” | osobna funkcja odswiezLicznik() wywoływana w 3 miejscach | wyrażenie {zadania.filter(...).length} w JSX |
| pokaż/ukryj | el.style.display = 'none' | {widoczne && <p>…</p>} |
| odczyt pola | input.value w momencie kliknięcia | stan tekst aktualizowany w onChange |
| zdarzenia | addEventListener('click', f) | onClick={f} |
| wiele komponentów | ręczne dzielenie na funkcje, ryzyko kolizji id | komponenty z props, każdy z własnym stanem |
React nie zastępuje wiedzy o DOM – pod spodem i tak wywołuje createElement i textContent. Zastępuje tylko twój kod synchronizujący. Na egzaminie INF.03 nadal piszesz ten kod sam; w projekcie z klasy 4 i w INF.04 oddajesz go bibliotece.
Dla porównania – licznik z informatora INF.04 w stylu Angular (TypeScript, dekorator @Component, szablon HTML z własną składnią zdarzeń):
@Component({
selector: 'app-licznik',
template: `<button (click)="dodaj()">Kliknięto: {{ liczba }}</button>`,
})
export class LicznikComponent {
liczba = 0;
dodaj() { this.liczba++; }
}Ta sama idea (stan → szablon), inna składnia: (click) zamiast onClick, {{ }} zamiast { }, klasa zamiast funkcji, zwykłe this.liczba++ zamiast setLiczba. Vue stoi gdzieś pomiędzy (@click, {{ liczba }}, ref(0)).
Pułapki – podsumowanie
| Błąd | Objaw | Poprawka |
|---|---|---|
zadania.push(z) zamiast setZadania([...]) | widok się nie odświeża | zawsze nowa tablica/obiekt |
onClick={usun(id)} | funkcja odpala się przy renderze, „Too many re-renders” | onClick={() => usun(id)} |
brak key lub key={index} | ostrzeżenie, pomylony stan wierszy | key={z.id} |
value bez onChange | pole nie przyjmuje tekstu | dodaj onChange albo użyj defaultValue |
hook w if lub pętli | „Rendered more hooks than during the previous render” | hooki tylko na górze komponentu |
useEffect bez [] + setState | nieskończona pętla | tablica zależności |
setLiczba(liczba + 1) dwa razy | +1 zamiast +2 | setLiczba(l => l + 1) |
class=, for=, onclick= | ostrzeżenia, zdarzenie nie działa | className, htmlFor, onClick |
<komponent /> małą literą | pusty element w DOM | nazwa komponentu wielką literą |
Ściąga
| Składnia | Co robi | Przykład |
|---|---|---|
function Nazwa(props) { return <…/>; } | komponent funkcyjny | function Karta({ tytul }) { … } |
<Nazwa a="x" b={5} /> | użycie komponentu z props | <Karta tytul="Hej" /> |
{wyrazenie} | wstawka JS w JSX | {cena.toFixed(2)} zł |
{warunek && <X/>} / {w ? <A/> : <B/>} | renderowanie warunkowe | {blad && <p>{blad}</p>} |
className, htmlFor, style={{…}} | atrybuty HTML w JSX | style={{ color: 'red' }} |
<>…</> | fragment (kilka elementów bez dodatkowego div) | <><h1/><p/></> |
const [s, setS] = useState(start) | stan komponentu | useState([]) |
setS(nowa) / setS(prev => …) | zmiana stanu (nowa referencja!) | setS(prev => prev + 1) |
tab.map(x => <li key={x.id}>…</li>) | lista | key unikalny i stabilny |
onClick={f} / onClick={() => f(a)} | zdarzenia | onChange={(e) => setT(e.target.value)} |
value={s} onChange={…} | pole kontrolowane | checked={s} dla checkbox |
useEffect(() => {…}, [zal]) | efekt po renderze | [] = raz na start |
return () => {…} w efekcie | funkcja czyszcząca | clearInterval, flaga aktualne |
createRoot(el).render(<App />) | montowanie aplikacji | w main.jsx |
npm create vite@latest nazwa -- --template react | nowy projekt | potem npm install, npm run dev |
Zadania
Przycisk „Lubię to”
Napisz komponent Polubienia, który pokazuje przycisk „Lubię to” i tekst „Polubień: N”. Każde kliknięcie zwiększa N o 1. Dodaj drugi przycisk „Wyzeruj”, aktywny tylko wtedy, gdy N jest większe od 0 (atrybut disabled). Przy N równym 1 tekst ma brzmieć „1 polubienie”, przy innych wartościach „N polubień”.
Pokaż rozwiązanie
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
function Polubienia() {
const [liczba, setLiczba] = useState(0);
// odmiana zależna od liczby – zwykła funkcja pomocnicza poza JSX
const etykieta = liczba === 1 ? '1 polubienie' : `${liczba} polubień`;
return (
<p>
<button onClick={() => setLiczba(liczba + 1)}>Lubię to</button>{' '}
<button onClick={() => setLiczba(0)} disabled={liczba === 0}>Wyzeruj</button>{' '}
{etykieta}
</p>
);
}
createRoot(document.getElementById('root')).render(<Polubienia />);
</script>Kalkulator rat w React
Przepisz kalkulator z lekcji 14 na React. Komponent KalkulatorRat ma:
- trzy checkboxy kursów z cenami: HTML (500 zł), CSS (400 zł), JavaScript (900 zł),
- pole
numberz liczbą rat (od 1 do 12, domyślnie 1), - pod formularzem akapit: „Koszt całkowity: X zł. Płacisz N rat po Y zł” – aktualizowany na żywo, bez przycisku „Oblicz”,
- gdy nic nie zaznaczono: „Wybierz co najmniej jeden kurs.”
Stan trzymaj w dwóch miejscach: tablica zaznaczonych nazw kursów oraz liczba rat. Ratę zaokrąglij do 2 miejsc (toFixed(2)).
Pokaż rozwiązanie
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
const KURSY = [
{ nazwa: 'HTML', cena: 500 },
{ nazwa: 'CSS', cena: 400 },
{ nazwa: 'JavaScript', cena: 900 },
];
function KalkulatorRat() {
const [wybrane, setWybrane] = useState([]); // tablica nazw, np. ['HTML', 'CSS']
const [raty, setRaty] = useState(1);
function przelacz(nazwa) {
// nowa tablica: albo bez tej nazwy, albo z nią dodaną
setWybrane(
wybrane.includes(nazwa) ? wybrane.filter((n) => n !== nazwa) : [...wybrane, nazwa]
);
}
// wartości pochodne liczysz w renderze – nie trzymasz ich w stanie
const koszt = KURSY.filter((k) => wybrane.includes(k.nazwa)).reduce((s, k) => s + k.cena, 0);
const rata = (koszt / raty).toFixed(2);
return (
<>
{KURSY.map((k) => (
<label key={k.nazwa} style={{ display: 'block' }}>
<input
type="checkbox"
checked={wybrane.includes(k.nazwa)}
onChange={() => przelacz(k.nazwa)}
/>{' '}
{k.nazwa} ({k.cena} zł)
</label>
))}
<label>
Liczba rat:{' '}
<input
type="number"
min="1"
max="12"
value={raty}
onChange={(e) => setRaty(Math.min(12, Math.max(1, Number(e.target.value) || 1)))}
/>
</label>
<p>
{wybrane.length === 0
? 'Wybierz co najmniej jeden kurs.'
: `Koszt całkowity: ${koszt} zł. Płacisz ${raty} rat po ${rata} zł`}
</p>
</>
);
}
createRoot(document.getElementById('root')).render(<KalkulatorRat />);
</script>Zwróć uwagę: koszt i rata nie są w useState. Da się je obliczyć z wybrane i raty, więc trzymanie ich w stanie tylko stwarzałoby ryzyko rozjazdu. Zasada: w stanie trzymasz minimum, resztę liczysz.
Lista zakupów z podsumowaniem i localStorage
Zbuduj aplikację z trzech komponentów: Formularz (nazwa produktu, cena, przycisk „Dodaj”), Lista (pozycje z przyciskiem „Usuń”) i Podsumowanie (liczba pozycji i suma cen). Stan trzyma rodzic App. Dodatkowo:
- lista ma być zapisywana w
localStorage(lekcja 25) przy każdej zmianie i odczytywana przy starcie, - przy sumie powyżej 100 zł
Podsumowaniema wyświetlić na czerwono „Przekroczono budżet”.
Wskazówka: wartość początkową useState możesz podać jako funkcję useState(() => …) – wykona się tylko raz, przy pierwszym renderze. Zapis do localStorage zrób w useEffect z zależnością [produkty].
Pokaż rozwiązanie
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
const KLUCZ = 'zakupy';
// localStorage może być niedostępny (tryb prywatny, sandbox placu zabaw) – stąd try/catch
function odczytaj() {
try {
return JSON.parse(localStorage.getItem(KLUCZ)) ?? [];
} catch {
return [];
}
}
function Formularz({ onDodaj }) {
const [nazwa, setNazwa] = useState('');
const [cena, setCena] = useState('');
function wyslij(e) {
e.preventDefault();
const liczba = parseFloat(cena);
if (nazwa.trim() === '' || Number.isNaN(liczba) || liczba < 0) return;
onDodaj({ id: Date.now(), nazwa: nazwa.trim(), cena: liczba });
setNazwa('');
setCena('');
}
return (
<form onSubmit={wyslij}>
<input value={nazwa} onChange={(e) => setNazwa(e.target.value)} placeholder="Produkt" />{' '}
<input value={cena} onChange={(e) => setCena(e.target.value)} type="number" step="0.01" placeholder="Cena" style={{ width: 80 }} />{' '}
<button>Dodaj</button>
</form>
);
}
function Lista({ produkty, onUsun }) {
if (produkty.length === 0) return <p>Lista jest pusta.</p>;
return (
<ul>
{produkty.map((p) => (
<li key={p.id}>
{p.nazwa} – {p.cena.toFixed(2)} zł <button onClick={() => onUsun(p.id)}>Usuń</button>
</li>
))}
</ul>
);
}
function Podsumowanie({ produkty }) {
const suma = produkty.reduce((s, p) => s + p.cena, 0);
return (
<p>
Pozycji: {produkty.length}, razem: <strong>{suma.toFixed(2)} zł</strong>
{suma > 100 && <span style={{ color: 'crimson' }}> – Przekroczono budżet</span>}
</p>
);
}
function App() {
const [produkty, setProdukty] = useState(odczytaj); // funkcja – odczyt tylko raz
useEffect(() => {
try {
localStorage.setItem(KLUCZ, JSON.stringify(produkty));
} catch {
// brak localStorage – aplikacja działa dalej, tylko bez zapisu
}
}, [produkty]); // po każdej zmianie listy
return (
<>
<Formularz onDodaj={(p) => setProdukty([...produkty, p])} />
<Lista produkty={produkty} onUsun={(id) => setProdukty(produkty.filter((p) => p.id !== id))} />
<Podsumowanie produkty={produkty} />
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>W placu zabaw na tej stronie ramka jest odizolowana i localStorage może rzucić błąd – dlatego odczyt i zapis są w try/catch. W projekcie Vite zapis działa normalnie: odśwież stronę, a lista zostanie.
Wyszukiwarka użytkowników z API
Napisz aplikację, która po starcie pobiera listę użytkowników z https://jsonplaceholder.typicode.com/users (10 osób; pola name, email, address.city, company.name) i wyświetla ją jako karty. Nad listą jest pole „Szukaj”, które filtruje karty na żywo po imieniu lub mieście (bez rozróżniania wielkości liter). Pokaż stany „Ładowanie…” i błąd (sprawdź odp.ok). Po kliknięciu karty rozwiń pod nią dodatkowe dane (email, firma) – kliknięcie ponowne zwija. Podziel kod na App, Szukaj i KartaUzytkownika; stan rozwinięcia trzymaj lokalnie w karcie.
Pokaż rozwiązanie
<div id="root"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.3.1",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client"
} }
</script>
<script src="https://unpkg.com/@babel/standalone@7.29.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
function Szukaj({ wartosc, onZmien }) {
return (
<p>
<label>
Szukaj (imię lub miasto):{' '}
<input value={wartosc} onChange={(e) => onZmien(e.target.value)} />
</label>
</p>
);
}
function KartaUzytkownika({ uzytkownik }) {
const [rozwinieta, setRozwinieta] = useState(false); // stan lokalny – dotyczy tylko tej karty
return (
<article
onClick={() => setRozwinieta(!rozwinieta)}
style={{ border: '1px solid #ccc', borderRadius: 8, padding: 8, marginBottom: 6, cursor: 'pointer' }}
>
<strong>{uzytkownik.name}</strong> – {uzytkownik.address.city}
{rozwinieta && (
<p style={{ margin: '6px 0 0' }}>
{uzytkownik.email}<br />
Firma: {uzytkownik.company.name}
</p>
)}
</article>
);
}
function App() {
const [uzytkownicy, setUzytkownicy] = useState([]);
const [laduje, setLaduje] = useState(true);
const [blad, setBlad] = useState(null);
const [fraza, setFraza] = useState('');
useEffect(() => {
async function pobierz() {
try {
const odp = await fetch('https://jsonplaceholder.typicode.com/users');
if (!odp.ok) throw new Error(`HTTP ${odp.status}`);
setUzytkownicy(await odp.json());
} catch (e) {
setBlad(e.message);
} finally {
setLaduje(false);
}
}
pobierz();
}, []); // tylko raz, po pierwszym renderze
const f = fraza.trim().toLowerCase();
const widoczni = uzytkownicy.filter(
(u) => u.name.toLowerCase().includes(f) || u.address.city.toLowerCase().includes(f)
);
return (
<>
<Szukaj wartosc={fraza} onZmien={setFraza} />
{laduje && <p>Ładowanie…</p>}
{blad && <p style={{ color: 'crimson' }}>Nie udało się pobrać danych: {blad}</p>}
{!laduje && !blad && widoczni.length === 0 && <p>Brak wyników dla „{fraza}”.</p>}
{widoczni.map((u) => <KartaUzytkownika key={u.id} uzytkownik={u} />)}
</>
);
}
createRoot(document.getElementById('root')).render(<App />);
</script>Dzięki key={u.id} karta rozwinięta pozostaje rozwinięta także po zmianie frazy – React rozpoznaje ten sam element, nawet gdy zmieniła się jego pozycja na liście. Z key={index} rozwinięcie „przeskakiwałoby” na inną osobę.
Test powtórkowy
Test powtórkowy
Format jak w części pisemnej INF.04: 4 odpowiedzi, 1 poprawna.
Który zapis w JSX ustawia klasę CSS elementu?
Co wyświetli przycisk po trzech kliknięciach?
function Licznik() {
let liczba = 0;
return <button onClick={() => liczba++}>{liczba}</button>;
}Jak poprawnie przekazać argument do funkcji obsługi kliknięcia?
Które wywołanie poprawnie dodaje element do stanu-tablicy?
const [zadania, setZadania] = useState([]);Po co elementom listy renderowanej przez map nadaje się atrybut key?
Co się stanie z tym polem po wpisaniu litery?
const [imie, setImie] = useState('Ala');
return <input value={imie} />;Komponent Lista dostaje dane od rodzica. Jak powinien poprosić o usunięcie elementu?
Ile razy wykona się ten efekt w trybie produkcyjnym?
useEffect(() => {
console.log('pobieram dane');
}, []);Którym poleceniem założysz nowy projekt React w JavaScripcie z użyciem Vite?
Co wyświetli komponent, gdy koszyk jest pustą tablicą?
return <div>{koszyk.length && <p>Masz produkty</p>}</div>;