Практичний проект
React практичний проєкт — робоче місце шефа з інгредієнтами в мисочках готовими до приготування
Протягом блоку ми вивчили компоненти, props, state, ефекти, context та routing окремо. Тепер час поєднати все в один додаток. Побудуємо Recipe Finder — пошук рецептів з API, збереження улюблених та навігація між сторінками.
Архітектура додатку
Ось як всі концепції React працюють разом:
<FavoritesProvider> ← Context (глобальний стан)
<BrowserRouter> ← Router (навігація)
<Layout> ← Спільна обгортка (header + footer)
<Routes>
/ → HomePage ← useState + useEffect (пошук + API)
/recipe/42 → RecipePage ← useParams + useEffect (деталі)
/favorites → FavoritesPage ← useContext (збережені рецепти)
</Routes>
</Layout>
</BrowserRouter>
</FavoritesProvider>
Кожна концепція має своє місце. Context обгортає все — щоб улюблені рецепти були доступні на будь-якій сторінці.
Структура проекту
src/
├── components/ # Перевикористовувані UI-компоненти
│ ├── RecipeCard.tsx # Картка рецепта (props: recipe)
│ ├── SearchBar.tsx # Поле пошуку (props: onSearch)
│ ├── Loader.tsx # Індикатор завантаження
│ └── Layout.tsx # Header + Outlet + Footer
├── pages/ # Сторінки (Route-level)
│ ├── HomePage.tsx # Пошук + результати
│ ├── RecipePage.tsx # Деталі рецепта
│ ├── FavoritesPage.tsx # Збережені рецепти
│ └── NotFound.tsx # 404
├── context/
│ └── FavoritesContext.tsx # Context для улюблених
├── App.tsx # Routing
└── main.tsx # Точка входу
Правило: components/ — маленькі перевикористовувані блоки, pages/ — цілі сторінки, що відповідають маршрутам, context/ — глобальний стан.
Крок 1: Context для улюблених
Починаємо з глобального стану — списку улюблених рецептів:
// src/context/FavoritesContext.tsx
import { createContext, useContext, useState } from 'react'
interface Recipe {
idMeal: string
strMeal: string
strMealThumb: string
}
interface FavoritesContextType {
favorites: Recipe[]
addFavorite: (recipe: Recipe) => void
removeFavorite: (id: string) => void
isFavorite: (id: string) => boolean
}
const FavoritesContext = createContext<FavoritesContextType | null>(null)
export function useFavorites() {
const ctx = useContext(FavoritesContext)
if (!ctx) throw new Error('useFavorites — потрібен FavoritesProvider')
return ctx
}
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
const [favorites, setFavorites] = useState<Recipe[]>([])
function addFavorite(recipe: Recipe) {
setFavorites(prev => [...prev, recipe])
}
function removeFavorite(id: string) {
setFavorites(prev => prev.filter(r => r.idMeal !== id))
}
function isFavorite(id: string) {
return favorites.some(r => r.idMeal === id)
}
return (
<FavoritesContext.Provider value={{ favorites, addFavorite, removeFavorite, isFavorite }}>
{children}
</FavoritesContext.Provider>
)
}
Крок 2: Routing
// src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { FavoritesProvider } from './context/FavoritesContext'
import Layout from './components/Layout'
import HomePage from './pages/HomePage'
import RecipePage from './pages/RecipePage'
import FavoritesPage from './pages/FavoritesPage'
import NotFound from './pages/NotFound'
export default function App() {
return (
<FavoritesProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="recipe/:id" element={<RecipePage />} />
<Route path="favorites" element={<FavoritesPage />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
</FavoritesProvider>
)
}
Крок 3: Головна сторінка (useState + useEffect)
Тут зосереджена основна логіка — пошук через API:
// src/pages/HomePage.tsx
import { useState, useEffect } from 'react'
import SearchBar from '../components/SearchBar'
import RecipeCard from '../components/RecipeCard'
import Loader from '../components/Loader'
export default function HomePage() {
const [query, setQuery] = useState('')
const [recipes, setRecipes] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return
setLoading(true)
fetch(`https://www.themealdb.com/api/json/v1/1/search.php?s=${query}`)
.then(res => res.json())
.then(data => setRecipes(data.meals || []))
.finally(() => setLoading(false))
}, [query])
return (
<div>
<h1>Пошук рецептів</h1>
<SearchBar onSearch={setQuery} />
{loading && <Loader />}
<div className="recipe-grid">
{recipes.map(recipe => (
<RecipeCard key={recipe.idMeal} recipe={recipe} />
))}
</div>
</div>
)
}
Зверни увагу на патерн: useState для даних → useEffect для завантаження → компоненти через props.
Крок 4: Компонент RecipeCard (props + context + навігація)
Ось де перетинаються відразу три концепції:
// src/components/RecipeCard.tsx
import { Link } from 'react-router-dom'
import { useFavorites } from '../context/FavoritesContext'
interface RecipeCardProps {
recipe: {
idMeal: string
strMeal: string
strMealThumb: string
}
}
export default function RecipeCard({ recipe }: RecipeCardProps) {
const { isFavorite, addFavorite, removeFavorite } = useFavorites()
const saved = isFavorite(recipe.idMeal)
return (
<div className="recipe-card">
<img src={recipe.strMealThumb} alt={recipe.strMeal} />
<h3>{recipe.strMeal}</h3>
<div className="actions">
<Link to={`/recipe/${recipe.idMeal}`}>Детальніше</Link>
<button onClick={() => saved ? removeFavorite(recipe.idMeal) : addFavorite(recipe)}>
{saved ? 'Видалити' : 'В улюблені'}
</button>
</div>
</div>
)
}
Цей компонент: отримує дані через props, читає/змінює context, і використовує Link для навігації.
Крок 5: Сторінка рецепта (useParams + useEffect)
// src/pages/RecipePage.tsx
import { useParams } from 'react-router-dom'
import { useState, useEffect } from 'react'
export default function RecipePage() {
const { id } = useParams<{ id: string }>()
const [recipe, setRecipe] = useState(null)
useEffect(() => {
fetch(`https://www.themealdb.com/api/json/v1/1/lookup.php?i=${id}`)
.then(res => res.json())
.then(data => setRecipe(data.meals?.[0] || null))
}, [id])
if (!recipe) return <p>Завантаження...</p>
return (
<div>
<h1>{recipe.strMeal}</h1>
<img src={recipe.strMealThumb} alt={recipe.strMeal} />
<p>{recipe.strInstructions}</p>
</div>
)
}
Типові помилки
| Помилка | Правильно |
|---|---|
Мутація стану: favorites.push(recipe) | setFavorites(prev => [...prev, recipe]) |
Відсутній key в списках | Завжди key={recipe.idMeal} |
useEffect без масиву залежностей | Вказуй [query] або [] — інакше нескінченні запити |
<a href="/recipe/1"> для навігації | <Link to="/recipe/1"> — не перезавантажує сторінку |
Підсумок
Ми побудували додаток, що використовує всі концепції React-блоку:
- Components + Props — RecipeCard, SearchBar, Layout
- useState — пошуковий запит, список рецептів, loading
- useEffect — завантаження даних з API
- Context — глобальний список улюблених
- React Router — навігація між сторінками, useParams, Link
Це та ж архітектура, що використовується в реальних додатках — тільки масштаб менший. Ти вже знаєш усі будівельні блоки!
Що далі?
Блок React завершено! У домашніх завданнях побудуєш свої додатки з нуля. А далі — Next.js, де React-додатки стають повноцінними вебсайтами з серверним рендерингом.
API для практики:
- TheMealDB — рецепти (безкоштовно, без ключа)
- TMDB — фільми (потрібен безкоштовний ключ)
- JSONPlaceholder — фейкові дані для тестування
Корисні посилання:
- React.dev: Thinking in React — як планувати React-додаток
- React.dev: Managing State — гайд по організації стану