English flagEnglish

7b. gyakorlat – React Router, Props és Formok

2026-04-12 7 perc olvasási idő GitHub

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
  • Link komponenssel 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>
FogalomMagyarázat
<Routes>Konténer — egyszerre csak az aktív URL-nek megfelelő Route renderelődik
<Route path="...">Egy útvonal és a hozzá tartozó komponens
:idDinamikus 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 meg
  • localhost:5173/kosar → a kosár oldal
  • localhost:5173/termekek/1 → az 1-es termék részletei

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.

// 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

FogalomSzerepe
<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

FogalomPélda
Állapot felemeléseKosár state az App-ban, nem a kártyában
Prop drillingcartCount Navbar-nak, onAddToCart ProductCard-nak
Derived valuecartCount = cartItems.reduce(...) — nem külön state
Funkcionális updateset(prev => [...prev, újElem]) ha az új érték az előzőtől függ

Kontrollált form

MintaKód
Objektum stateuseState({ nev: '', email: '', ... })
Generikus handlersetFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
Form submite.preventDefault() → validáció → setSubmitted(true)
Programatikus redirectconst navigate = useNavigate() majd navigate('/')

Custom hook

  • Neve use-zal kezdődik
  • Belül hook-okat hívhat (useState, useEffect stb.)
  • Újrafelhasználható logikát csomagol — az App.tsx csak UI összeszerkesztést végez
  • Minden meghívóban saját state példányt kap (nem osztott)