Przejdź do treści

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.

licznik-vanilla.js
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:

Licznik.jsx
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”.

Cztery kroki: zdarzenie onClick, zapisanie nowego stanu przez setLiczba, ponowne wywołanie funkcji Licznik, porównanie i aktualizacja DOM przez React
Rys. 1. Cykl renderowania: ty opisujesz UI dla stanu, React aktualizuje DOM.

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).

Terminal
npm create vite@latest lista-zadan -- --template react
cd lista-zadan
npm install
npm run dev

Podwó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):

lista-zadan/
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.css
src/main.jsx
import { 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:

szablon placu zabaw
<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:

HTMLJSXDlaczego
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.

Sprawdź się

Co wyświetli ten komponent przy ocena = 2?

function Wynik({ ocena }) {
return (
  <p>
    Ocena: {ocena} {ocena >= 2 ? 'zdane' : 'niezdane'}
    {ocena === 6 && ' – celująco!'}
  </p>
);
}
JSX: wyrażenia, warunki, style
<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>
Podgląd

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).

KartaProduktu.jsx
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”:

Ramka.jsx
function Ramka({ tytul, children }) {
  return (
    <section className="ramka">
      <h2>{tytul}</h2>
      {children}
    </section>
  );
}

<Ramka tytul="Uwaga">
  <p>Ten tekst trafi do children.</p>
</Ramka>
Komponenty i props: sklep
<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>
Podgląd

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.

useState – anatomia
const [liczba, setLiczba] = useState(0);
//     ^ aktualna wartość   ^ funkcja zmieniająca   ^ wartość początkowa

useState 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ń.

Sprawdź się

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>;
}
useState: licznik i przełącznik
<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>
Podgląd

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:

Stan bez mutacji
// 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).

Zdarzenia – trzy poprawne formy
<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.

Lista z map i key
<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>
Podgląd

„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.

Pole kontrolowane
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.

Formularz kontrolowany: dodawanie zadań
<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>
Podgląd

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.

Komponent App trzyma stan zadania i filtr; strzałki w dół do komponentów Formularz i Lista oznaczają props, przerywane strzałki w górę oznaczają wywołania funkcji onDodaj i onUsun
Rys. 2. Stan w rodzicu, props w dół, wywołania funkcji w górę.
Podnoszenie stanu: filtr + lista + formularz
<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>
Podgląd

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ą).

useEffect + fetch: lista zadań z API
<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>
Podgląd

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ń

ZadanieCzysty JS (lekcje 16–17)React
gdzie jest stanzmienna globalna let zadania = []const [zadania, setZadania] = useState([])
dodanie elementuzadania.push(z) + createElement + appendChildsetZadania([...zadania, z]) – widok sam się odświeży
licznik „pozostało”osobna funkcja odswiezLicznik() wywoływana w 3 miejscachwyrażenie {zadania.filter(...).length} w JSX
pokaż/ukryjel.style.display = 'none'{widoczne && <p>…</p>}
odczyt polainput.value w momencie kliknięciastan tekst aktualizowany w onChange
zdarzeniaaddEventListener('click', f)onClick={f}
wiele komponentówręczne dzielenie na funkcje, ryzyko kolizji idkomponenty 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ń):

licznik.component.ts (Angular)
@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łądObjawPoprawka
zadania.push(z) zamiast setZadania([...])widok się nie odświeżazawsze 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 wierszykey={z.id}
value bez onChangepole nie przyjmuje tekstudodaj 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 [] + setStatenieskończona pętlatablica zależności
setLiczba(liczba + 1) dwa razy+1 zamiast +2setLiczba(l => l + 1)
class=, for=, onclick=ostrzeżenia, zdarzenie nie działaclassName, htmlFor, onClick
<komponent /> małą literąpusty element w DOMnazwa komponentu wielką literą

Ściąga

SkładniaCo robiPrzykład
function Nazwa(props) { return <…/>; }komponent funkcyjnyfunction 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 JSXstyle={{ color: 'red' }}
<>…</>fragment (kilka elementów bez dodatkowego div)<><h1/><p/></>
const [s, setS] = useState(start)stan komponentuuseState([])
setS(nowa) / setS(prev => …)zmiana stanu (nowa referencja!)setS(prev => prev + 1)
tab.map(x => <li key={x.id}>…</li>)listakey unikalny i stabilny
onClick={f} / onClick={() => f(a)}zdarzeniaonChange={(e) => setT(e.target.value)}
value={s} onChange={…}pole kontrolowanechecked={s} dla checkbox
useEffect(() => {…}, [zal])efekt po renderze[] = raz na start
return () => {…} w efekciefunkcja czyszczącaclearInterval, flaga aktualne
createRoot(el).render(<App />)montowanie aplikacjiw main.jsx
npm create vite@latest nazwa -- --template reactnowy projektpotem npm install, npm run dev

Zadania

Zadanie 1
łatwe

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
Rozwiązanie: Polubienia
<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>
Podgląd
Zadanie 2
średnie

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 number z 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
Rozwiązanie: KalkulatorRat
<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>
Podgląd

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.

Zadanie 3
średnie

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ł Podsumowanie ma 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
Rozwiązanie: lista zakupów
<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>
Podgląd

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.

Zadanie 4
trudne

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
Rozwiązanie: wyszukiwarka użytkowników
<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>
Podgląd

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.

0/0poprawnych
Sprawdź się

Który zapis w JSX ustawia klasę CSS elementu?

Sprawdź się

Co wyświetli przycisk po trzech kliknięciach?

function Licznik() {
let liczba = 0;
return <button onClick={() => liczba++}>{liczba}</button>;
}
Sprawdź się

Jak poprawnie przekazać argument do funkcji obsługi kliknięcia?

Sprawdź się

Które wywołanie poprawnie dodaje element do stanu-tablicy?

const [zadania, setZadania] = useState([]);
Sprawdź się

Po co elementom listy renderowanej przez map nadaje się atrybut key?

Sprawdź się

Co się stanie z tym polem po wpisaniu litery?

const [imie, setImie] = useState('Ala');
return <input value={imie} />;
Sprawdź się

Komponent Lista dostaje dane od rodzica. Jak powinien poprosić o usunięcie elementu?

Sprawdź się

Ile razy wykona się ten efekt w trybie produkcyjnym?

useEffect(() => {
console.log('pobieram dane');
}, []);
Sprawdź się

Którym poleceniem założysz nowy projekt React w JavaScripcie z użyciem Vite?

Sprawdź się

Co wyświetli komponent, gdy koszyk jest pustą tablicą?

return <div>{koszyk.length && <p>Masz produkty</p>}</div>;