Вивчай

Практичний проект

React практичний проєкт — робоче місце шефа з інгредієнтами в мисочках готовими до приготування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 — фейкові дані для тестування

Корисні посилання: