7b. gyakorlat – React Router, Props és Formok
Bevezetés
A sandbox gyakorlatban megismertük a useState-et és a useEffect-et. Most ugyanezeket az eszközöket valódi kontextusban alkalmazzuk: egy félkész webshop alkalmazást kell teljessé tennünk.
A mai óra három fő témára bontható:
- React Router – SPA-navigáció, URL-váltás valódi oldalváltás nélkül
- Props és prop drilling – adatok és handler-ek átadása szülő-gyermek irányban
- Kontrollált formok és custom hook – beviteli mezők kezelése, logika kiszervezése
A mai óra után képes leszel:
- React Router-rel útvonalakat definiálni és köztük navigálni
Linkkomponenssel oldalváltást végezni HTTP-kérés nélkül- Kosár state-et az App szintjén kezelni és prop-okként szétosztani
useParams-szal dinamikus URL szegmenst kiolvasni- Objektum alapú form state-et generikus handler-rel kezelni
- Custom hook-ba kiszervezni az újrafelhasználható logikát
A projekt struktúrája
webshop-react-handout/
└── src/
├── App.tsx # Gyökérkomponens – router + kosár state
├── main.tsx # BrowserRouter wrapper
├── components/
│ ├── Navbar.tsx # Navigáció + kosár ikon
│ ├── Footer.tsx # Lábléc
│ └── ProductCard.tsx # Termék kártya
├── pages/
│ ├── HomePage.tsx # Főoldal
│ ├── ProductsPage.tsx # Termékek listája
│ ├── ProductDetailPage.tsx # Termék részletei
│ ├── CartPage.tsx # Kosár
│ └── CheckoutPage.tsx # Pénztár
├── hook/
│ └── useCart.ts # Kosár logika custom hook-ban
├── types/
│ └── index.ts # TypeScript típusok
└── data/
└── products.json # Termék adatok
A projekt elindítása:
cd gyak7v2/webshop-react-handout
npm install
npm run dev
1. React Router – telepítés és beállítás
Mi az a React Router?
A React egy single-page application (SPA) keretrendszer: az egész alkalmazás egyetlen HTML fájlban él, és a JavaScript cseréli a tartalmat. Azonban:
- A felhasználónak tudnia kell bookmarkolni az oldalakat
- A böngésző vissza gombja működjön
- Linkek oszthatók legyenek más felhasználókkal
Mindehhez az URL-nek változnia kell — de valódi oldalváltás (HTTP-kérés, lap-újratöltés) nélkül. Erre való a React Router.
A React Router nem része a React alapcsomagnak — külön csomag, amely telepítve van a projektben (react-router v7).
BrowserRouter – az alkalmazás becsomagolása
A React Router-nek tudnia kell figyelni a böngésző URL-jét. Ehhez az egész alkalmazást be kell csomagolni egy BrowserRouter komponensbe — ez a legkülső réteg.
A main.tsx-ben:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { BrowserRouter } from 'react-router'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
A BrowserRouter a History API-t használja a háttérben — ez teszi lehetővé, hogy az URL változzon anélkül, hogy a szerver valódi kérést kapna.
Routes és Route – útvonalak definiálása
Az App.tsx-ben a <main> tartalma:
import { Route, Routes } from 'react-router';
<main>
<Routes>
<Route path="/" element={<HomePage onAddToCart={addToCart} />} />
<Route path="/termekek" element={<ProductsPage onAddToCart={addToCart} />} />
<Route path="/termekek/:id" element={<ProductDetailPage onAddToCart={addToCart} />} />
<Route path="/kosar" element={<CartPage cartItems={cartItems} onRemove={removeFromCart} onUpdateQuantity={updateQuantity} />} />
<Route path="/fizetes" element={<CheckoutPage cartItems={cartItems} onOrderComplete={() => clearCart()} />} />
</Routes>
</main>
| Fogalom | Magyarázat |
|---|---|
<Routes> | Konténer — egyszerre csak az aktív URL-nek megfelelő Route renderelődik |
<Route path="..."> | Egy útvonal és a hozzá tartozó komponens |
:id | Dinamikus URL szegmens — pl. /termekek/3 esetén id = "3" |
element={<Komp />} | Az aktív URL-hez renderelendő komponens |
Teszteld: Gépeld be kézzel a böngésző URL-sávjába:
localhost:5173/termekek→ a termék lista oldal jelenik meglocalhost:5173/kosar→ a kosár oldallocalhost:5173/termekek/1→ az 1-es termék részletei
2. Link – navigáció bekötése
Miért nem elég a <a href>?
<!-- Hagyományos HTML link — az egész SPA újraindul! -->
<a href="/termekek">Termékek</a>
A hagyományos <a> valódi HTTP-kérést indít — a böngésző újratölti az oldalt, a React állapota (kosár, form adatok stb.) elvész. SPA-kban erre nem kerülhet sor.
A React Router <Link> komponense az URL-t a böngésző History API-ján keresztül változtatja meg — valódi HTTP-kérés nélkül. Csak a szükséges komponens renderelődik újra.
Navbar bekötése
// components/Navbar.tsx
import { Link } from 'react-router';
// Logo — főoldalra navigál
<Link to="/" className="flex items-center gap-2.5">
<span className="font-bold text-xl">WebShop</span>
</Link>
// Navigációs linkek
<Link to="/" className="...">Főoldal</Link>
<Link to="/termekek" className="...">Termékek</Link>
// Kosár ikon
<Link to="/kosar" className="relative p-2 ...">
{/* ikon */}
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 ...">
{cartCount}
</span>
)}
</Link>
A to prop felváltja a href-et. Minden más attribútum (className, tartalom) változatlan marad.
ProductCard bekötése
A termék nevére kattintva a részletoldalra kell navigálni:
// components/ProductCard.tsx
import { Link } from 'react-router';
<Link to={`/termekek/${product.id}`}>
<h3 className="font-semibold ...">{product.name}</h3>
</Link>
A template literal az adott termék ID-ját illeszti be az URL-be.
Az <a> helyett mindig <Link>-et használj Reaktban belső linkekhez. A <a>-t csak külső URL-ekhez tartsd fenn (pl. target="_blank" Spotify, YouTube linkekhez).
3. useState – kosár state és prop drilling
Hol tároljuk a kosár adatait?
A kosár több, egymástól független komponensnél szükséges:
- Navbar — hány elem van a kosárban (badge száma)
- ProductCard / ProductsPage — „Kosárba” gomb
- ProductDetailPage — „Kosárba helyezés” gomb mennyiséggel
- CartPage — a kosár teljes tartalmát megjeleníti
- CheckoutPage — a rendelés összegét és tételeit listázza
Ezek a komponens-fa különböző ágain találhatók. A React szabálya: az állapotot a legközelebbi közős szülőbe kell emelni. A legközelebbi közős szülő az App komponens.
Mi az a prop drilling?
Az adatokat „lefelé” adjuk át props-okon keresztül a szülőtől a gyermekek felé:
App
├── Navbar (cartCount prop)
└── main
└── Routes
├── ProductsPage (onAddToCart prop)
│ └── ProductCard (onAddToCart prop)
├── CartPage (cartItems, onRemove, onUpdateQuantity props)
└── CheckoutPage (cartItems, onOrderComplete props)
Ez a prop drilling — az adatok a gyökérből egyre mélyebbre áramlanak. Kisebb alkalmazásokban ez elfogadható megközelítés.
A kosár state az App-ban
// App.tsx
import useCart from './hook/useCart'
function App() {
const { cartItems, cartCount, addToCart, removeFromCart, updateQuantity, clearCart } = useCart()
return (
<div className="min-h-screen bg-slate-50">
<Navbar cartCount={cartCount} />
<main>
<Routes>
{/* ... routes a kosár függvényekkel ... */}
</Routes>
</main>
<Footer />
</div>
)
}
A cartCount egy derived value — a cartItems array-ből számítjuk, nem tárolunk külön state-ben. Ez kerüli az inkonzisztenciát: ha a cartItems változik, cartCount automatikusan frissül.
A kosárkezelő függvények
addToCart — ha a termék már a kosárban van, növeli a mennyiséget; ha nincs, hozzáadja:
const addToCart = (product: Product, quantity: number = 1) => {
setCartItems(prev => {
const existing = prev.find(item => item.product.id === product.id)
if (existing) {
return prev.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
)
}
return [...prev, { product, quantity }]
})
}
Figyeld meg a setCartItems(prev => ...) szintaxist — ez a funkcionális update. Az előző értéktől (prev) kiindulva számítja az új state-et. Akkor szükséges, ha az új érték az előzőtől függ — pl. mennyiség növelésekor.
removeFromCart — az adott termék eltávolítása:
const removeFromCart = (productId: number) => {
setCartItems(prev => prev.filter(item => item.product.id !== productId))
}
updateQuantity — mennyiség módosítása; ha 0-ra csökken, törli a terméket:
const updateQuantity = (productId: number, quantity: number) => {
if (quantity <= 0) {
removeFromCart(productId)
return
}
setCartItems(prev =>
prev.map(item =>
item.product.id === productId ? { ...item, quantity } : item
)
)
}
4. ProductsPage – keresés, szűrés, useEffect
A ProductsPage.tsx négy state-et kezel:
const [products, setProducts] = useState<Product[]>(allProducts)
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [activeCategory, setActiveCategory] = useState('Összes')
Betöltés szimulálása
useEffect(() => {
const timer = setTimeout(() => {
setProducts(allProducts)
setIsLoading(false)
}, 1000)
return () => clearTimeout(timer)
}, [])
Az üres [] garantálja, hogy csak egyszer fut le — a komponens mountolásakor. Ez az API-hívás késleltetését szimulálja. Amíg isLoading igaz, egy töltési üzenetet jelenítünk meg:
if (isLoading) {
return <p className="text-slate-500">Termékek betöltése...</p>
}
Szűrés derived value-ként
const filteredProducts = products.filter(p => {
const matchesCategory = activeCategory === 'Összes' || p.category === activeCategory
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
A filteredProducts nem külön state — közvetlenül a renderben számítjuk ki. Ez garantálja, hogy mindig szinkronban van a searchQuery-vel és az activeCategory-val.
Keresőmező bekötése
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Termék keresése..."
/>
Kategória szűrő
{allCategories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={cat === activeCategory
? 'bg-violet-600 text-white'
: 'bg-white text-slate-600 border border-slate-200'
}
>
{cat}
</button>
))}
Az aktív kategória gombja vizuálisan ki van emelve — a className feltételesen változik a state alapján.
5. ProductDetailPage – useParams és mennyiség
Mi az a useParams?
A React Router useParams hook-ja kiolvasza a dinamikus URL szegmenseket. Az /termekek/:id útvonalon a :id szegmens értéke:
import { useParams } from 'react-router'
const { id } = useParams()
// Ha az URL /termekek/3, akkor id === "3" (string!)
A useParams mindig string-et ad vissza, még akkor is, ha számnak tűnik az érték. Ha termék ID-val keresel a tömbben, konvertálni kell: Number(id).
A termék megkeresése és mennyiség state
export default function ProductDetailPage({ onAddToCart }) {
const { id } = useParams()
const product = products.find(p => p.id === Number(id))
if (!product) {
return <p>Ez a termék nem található ({id})</p>
}
const [quantity, setQuantity] = useState(1)
return (
// ...
<button onClick={() => setQuantity(Math.max(1, quantity - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(quantity + 1)}>+</button>
<button
disabled={!product.inStock}
onClick={() => onAddToCart(product, quantity)}
>
Kosárba helyezés
</button>
)
}
A Math.max(1, quantity - 1) megakadályozza, hogy a mennyiség 0 alá essen.
A useState hívása az if (!product) blokk után van — ez a hookszabályt sérti (hookot nem hívhatsz feltétel belsejéből). A helyes megközelítés: a useState-et a feltétel elé kell mozgatni, vagy az egész feltételes JSX-t egy korábbi return-nel kezelni. A React linter erre figyelmeztet.
6. CartPage – props bekötése
A CartPage minden adatát props-on keresztül kapja — saját state-je nincs. Ez jól mutatja a prop drilling mintát: az App az egyetlen forrás (single source of truth).
// pages/CartPage.tsx
interface CartPageProps {
cartItems: CartItem[]
onRemove: (productId: number) => void
onUpdateQuantity: (productId: number, quantity: number) => void
}
export default function CartPage({ cartItems, onRemove, onUpdateQuantity }: CartPageProps) {
const isEmpty = cartItems.length === 0
const subtotal = cartItems.reduce(
(sum, item) => sum + item.product.price * item.quantity, 0
)
// ...
}
A törlés és mennyiség gombok a prop-ként kapott handler-eket hívják:
{/* Törlés gomb */}
<button onClick={() => onRemove(product.id)}>
<TrashIcon />
</button>
{/* Mennyiség kontrollok */}
<button onClick={() => onUpdateQuantity(product.id, quantity - 1)}>-</button>
<span>{quantity}</span>
<button onClick={() => onUpdateQuantity(product.id, quantity + 1)}>+</button>
A CartPage nem tudja, hogyan működik a kosár belülről — csak meghívja a handler-eket. Ez a separation of concerns: a megjelenítés és a logika szétválik.
7. CheckoutPage – kontrollált form
A form state struktúrája
Több beviteli mező esetén hatékonyabb egyetlen objektum state-et használni mezőnként külön state helyett:
const [formData, setFormData] = useState({
nev: '',
email: '',
telefon: '',
iranyitoszam: '',
varos: '',
cim: '',
fizetesiMod: 'kartya',
megjegyzes: '',
})
Ez kevesebb useState hívást jelent, és az egész form állapota egyetlen helyen van.
Generikus handleChange
Ha minden input name attribútuma megegyezik az objektum kulcsával, egyetlen handler elegendő az összes mezőhöz:
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
A [name]: value szintaxis computed property name — JavaScript az aktuális name értékét használja kulcsként. A spread (...prev) gondoskodik arról, hogy a többi mező értéke ne törlődjön.
Az inputoknál:
<input
type="text"
name="nev"
value={formData.nev}
onChange={handleChange}
placeholder="Kovács János"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="kovacs.janos@email.hu"
/>
Mindkét input ugyanazt a handleChange függvényt használja — a name attribútum alapján az megfelelő mezőt frissíti.
Form beküldése
const [submitted, setSubmitted] = useState(false)
const navigate = useNavigate()
const onSubmit = (e: React.FormEvent) => {
e.preventDefault() // megakadályozza az oldal újratöltését
if (!formData.nev || !formData.email || !formData.varos || !formData.cim) {
alert('Kérlek, töltsd ki a kötelező mezőket!')
return
}
setSubmitted(true)
onOrderComplete()
}
if (submitted) {
return (
<div>
<h4>Sikeres rendelés!</h4>
<button onClick={() => navigate('/')}>Vissza a főoldalra</button>
</div>
)
}
A useNavigate hook programatikus navigálást tesz lehetővé — kódból tudunk URL-t váltani anélkül, hogy a felhasználónak kattintania kellene. Hasznos redirect esetén (pl. sikeres rendelés után visszairányítjuk a főoldalra).
A form onSubmit bekötése:
<form onSubmit={onSubmit}>
{/* mezők... */}
<button type="submit">Rendelés megerősítése</button>
</form>
8. Custom hook – useCart
Miért érdemes custom hook-ot írni?
A kosárkezelő logika (addToCart, removeFromCart, updateQuantity, cartCount, clearCart) az App.tsx-ben él. Amint az alkalmazás növekszik, az App.tsx egyre zsúfoltabbá válik. A megoldás: a logikát custom hook-ba szervezzük ki.
A custom hook egyszerűen egy JavaScript függvény, amelynek neve use-zal kezdődik, és belül hook-okat hív meg:
// hook/useCart.ts
import { useState } from 'react'
import type { CartItem, Product } from '../types'
export const useCart = () => {
const [cartItems, setCartItems] = useState<CartItem[]>([])
const addToCart = (product: Product, quantity: number = 1) => {
setCartItems(prev => {
const existing = prev.find(item => item.product.id === product.id)
if (existing) {
return prev.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
)
}
return [...prev, { product, quantity }]
})
}
const removeFromCart = (productId: number) => {
setCartItems(prev => prev.filter(item => item.product.id !== productId))
}
const updateQuantity = (productId: number, quantity: number) => {
if (quantity <= 0) { removeFromCart(productId); return }
setCartItems(prev =>
prev.map(item =>
item.product.id === productId ? { ...item, quantity } : item
)
)
}
const cartCount = cartItems.reduce((sum, item) => sum + item.quantity, 0)
const clearCart = () => setCartItems([])
return { cartItems, addToCart, removeFromCart, updateQuantity, cartCount, clearCart }
}
export default useCart
Az App.tsx-ben egyetlen sor váltja a teljes kosár state logikát:
import useCart from './hook/useCart'
function App() {
const { cartItems, cartCount, addToCart, removeFromCart, updateQuantity, clearCart } = useCart()
// Az App.tsx most kizárólag az UI összeszerkesztésével foglalkozik
}
A custom hook nem osztja meg az állapotot több komponens között — minden meghívóban saját, független state példányt kap. Ha globálisan megosztott állapotra van szükség (pl. bejelentkezett felhasználó), a React Context API vagy egy state management könyvtár (Zustand, Redux) a megoldás.
Összefoglalás
React Router
| Fogalom | Szerepe |
|---|---|
<BrowserRouter> | main.tsx-ben becsomagolja az egész appot |
<Routes> + <Route> | URL → komponens leképezés |
<Link to="..."> | Oldalváltás HTTP-kérés nélkül |
useParams() | Dinamikus URL szegmens kiolvasása (mindig string!) |
useNavigate() | Programatikus navigálás kódból |
State kezelés és props
| Fogalom | Példa |
|---|---|
| Állapot felemelése | Kosár state az App-ban, nem a kártyában |
| Prop drilling | cartCount Navbar-nak, onAddToCart ProductCard-nak |
| Derived value | cartCount = cartItems.reduce(...) — nem külön state |
| Funkcionális update | set(prev => [...prev, újElem]) ha az új érték az előzőtől függ |
Kontrollált form
| Minta | Kód |
|---|---|
| Objektum state | useState({ nev: '', email: '', ... }) |
| Generikus handler | setFormData(prev => ({ ...prev, [e.target.name]: e.target.value })) |
| Form submit | e.preventDefault() → validáció → setSubmitted(true) |
| Programatikus redirect | const navigate = useNavigate() majd navigate('/') |
Custom hook
- Neve
use-zal kezdődik - Belül hook-okat hívhat (
useState,useEffectstb.) - Újrafelhasználható logikát csomagol — az
App.tsxcsak UI összeszerkesztést végez - Minden meghívóban saját state példányt kap (nem osztott)