React.js. Приложение каталога рецептов

27.08.2021

Теги: APIFrontendJavaScriptWeb-разработкаКомпонентПрактика

Небольшое приложение — каталог рецептов блюд всего мира, не имеет практической ценности, сделано исключительно с целью изучения React. Для оформления используется css-фреймворк materialize.css, http-запросы на получение рецептов отправляются сервису themealdb.com. Итак, разворачиваем react-приложение.

> npx create-react-app .

Подготовительные работы

Все лишнее из директории src удалим, оставим только index.js, index.css и App.js:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

ReactDOM.render(
    <React.StrictMode>
        <App />
    </React.StrictMode>,
    document.getElementById('root')
);
body {
    margin: 0;
    padding: 0;
    font-family: Arial, Helvetica, sans-serif;
}
#root {
    /* flex-контейнер для <header>, <main> и <footer> */
    display: flex;
    /* flex-элементы <header>, <main> и <footer> выстаиваются по вертикали */
    flex-direction: column;
    /* вся высота viewport браузера */
    min-height: 100vh;
}
main {
    padding-top: 20px;
    /*
     * может как увеличиваться, так и уменьшаться, чтобы вместе с
     * <header> и <footer> занять всю высоту viewport браузера
     */
    flex: 1 1 auto;
}
.page-footer {
    padding-top: 10px;
    padding-bottom: 10px;
}
nav {
    box-shadow: none;
}
import {BrowserRouter as Router} from 'react-router-dom';
import Header from './layout/Header';
import Footer from './layout/Footer';
import Content from './layout/Content';

export default function App() {
    return (
        <Router>
            <Header />
            <Content />
            <Footer />
        </Router>
    );
}

Создадим директорию src/layout и разместим в ней три компонента — Header.js, Footer.js и Content.js.

import {Link} from 'react-router-dom';

export default function Header(props) {
    return (
        <header>
            <nav className="teal lighten-1">
                <div className="nav-wrapper container">
                    <Link to="/" className="brand-logo">Meal</Link>
                    <ul id="nav-mobile" className="right hide-on-med-and-down">
                        <li><Link to="/about">About</Link></li>
                        <li><Link to="/contact">Contact</Link></li>
                    </ul>
                </div>
            </nav>
        </header>
    );
}
export default function Footer() {
    return (
        <footer className="page-footer teal lighten-1">
            <div className="container">
                © {new Date().getFullYear()} All rights reserved
            </div>
        </footer>
    );
}
import {Route, Switch} from 'react-router-dom';
import Catalog from '../catalog/Catalog';
import About from '../pages/About';
import Contact from '../pages/Contact';
import NotFound from '../components/NotFound';

export default function Content() {
    return (
        <main className="container">
            <Switch>
                <Route exact path="/" component={Catalog} />
                <Route exact path="/about" component={About} />
                <Route exact path="/contact" component={Contact} />
                <Route component={NotFound} />
            </Switch>
        </main>
    );
}

На главной странице будет каталог рецептов, это компонент Catalog.js в директории src/catalog:

export default function Catalog() {
    return <h1>Categories</h1>
}

Компоненты статичных страниц About.js и Contact.js разместим в директории src/pages:

export default function About() {
    return (
        <>
            <h1>About page</h1>
            <p>
            Lorem ipsum dolor sit amet consectetur adipisicing elit. Non dolore maiores, adipisci,
            iste culpa consectetur corporis deserunt ratione amet, magnam quas. Non, cum quos
            doloremque consequuntur incidunt eveniet corrupti, et commodi temporibus fugit modi
            similique hic aut voluptas officia placeat numquam! Dicta eveniet, autem id veritatis.
            </p>
        </>
    );
}
export default function Contact() {
    return (
        <>
            <h1>Contact page</h1>
            <p>
            Lorem ipsum dolor sit amet consectetur adipisicing elit. Laborum temporibus voluptatum 
            laboriosam nam? Dolore optio autem eveniet culpa reprehenderit architecto blanditiis 
            pariatur cum nihil, voluptate sit atque, commodi fuga molestiae officia velit repudiandae 
            aliquam unde nisi quia laborum mollitia, voluptatem quo saepe. Sed rem, recusandae vero 
            error facilis quod mollitia.
            </p>
        </>
    );
}

Кроме того, создадим директорию src/components для вспомогательных компонентов:

export default function NotFound() {
    return <h1>Page not found</h1>
}
export default function Preloader() {
    return (
        <div className="progress">
            <div className="indeterminate"></div>
        </div>
    );
}

В файле public/index.html подключим css-фреймворк и иконки:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta name="description" content="Meal from all over the world" />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <link
        rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" />
        <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <title>Meal</title>
</head>
<body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
</body>
</html>

Работа с сервисом themealdb.com

Для изучения API потребуется установить расширение REST Client для VS Code (см. здесь). Создаем файл api.http:

### Все категории
GET www.themealdb.com/api/json/v1/1/categories.php

### Рецепты категории
www.themealdb.com/api/json/v1/1/filter.php?c=Seafood

### Отдельный рецепт
www.themealdb.com/api/json/v1/1/lookup.php?i=53050

Ответ на запрос на получение всех категорий:

{
    "categories":[
        {
            "idCategory": "1",
            "strCategory": "Beef",
            "strCategoryThumb": "https://www.themealdb.com/images/category/beef.png",
            "strCategoryDescription": "Beef is the culinary name for meat from cattle, particularly skeletal muscle..."
        },
        ..........
    ]
}

Ответ на запрос на получение рецептов категории:

{
    "meals": [
        {
            "strMeal":"Baked salmon with fennel & tomatoes",
            "strMealThumb":"https://www.themealdb.com/images/media/meals/1548772327.jpg",
            "idMeal":"52959"
        },
        ..........
    ]
}

Ответ на запрос на получение отдельного рецепта:

{
    "meals": [
        {
            "idMeal": "53050",
            "strMeal": "Ayam Percik",
            "strDrinkAlternate": null,
            "strCategory": "Chicken",
            "strArea": "Malaysian",
            "strInstructions": "In a blender, add the ingredients for the spice paste and blend until smooth....",
            "strMealThumb": "https://www.themealdb.com/images/media/meals/020z181619788503.jpg",
            "strTags": null,
            "strYoutube": "https://www.youtube.com/watch?v=9ytR28QK6I8",
            "strIngredient1": "Chicken Thighs",
            "strIngredient2": "Challots",
            "strIngredient3": "Ginger",
            "strIngredient4": "Garlic Clove",
            "strIngredient5": "Cayenne Pepper",
            "strIngredient6": "Turmeric",
            "strIngredient7": "Cumin",
            "strIngredient8": "Coriander",
            "strIngredient9": "Fennel",
            "strIngredient10": "Tamarind Paste",
            "strIngredient11": "Coconut Milk",
            "strIngredient12": "Sugar",
            "strIngredient13": "Water",
            "strIngredient14": "",
            "strIngredient15": "",
            "strIngredient16": "",
            "strIngredient17": "",
            "strIngredient18": "",
            "strIngredient19": "",
            "strIngredient20": "",
            "strMeasure1": "6",
            "strMeasure2": "16",
            "strMeasure3": "1 1/2 ",
            "strMeasure4": "6",
            "strMeasure5": "8",
            "strMeasure6": "2 tbs",
            "strMeasure7": "1 1/2",
            "strMeasure8": "1 1/2",
            "strMeasure9": "1 1/2 ",
            "strMeasure10": "2 tbs",
            "strMeasure11": "1 can",
            "strMeasure12": "1 tsp",
            "strMeasure13": "1 cup",
            "strMeasure14": "",
            "strMeasure15": "",
            "strMeasure16": "",
            "strMeasure17": "",
            "strMeasure18": "",
            "strMeasure19": "",
            "strMeasure20": "",
            "strSource": "http://www.curiousnut.com/roasted-spiced-chicken-ayam-percik/",
            "strImageSource": null,
            "strCreativeCommonsConfirmed": null,
            "dateModified": null
        }
    ]
}

Ключ API и URL, на который будем выполнять запросы, сохраним в отдельный файл config.js:

const API_KEY = '1';
export const API_URL = `https://www.themealdb.com/api/json/v1/${API_KEY}/`;

Функции для выполнения запросов

Функции, которые будут выполнять запросы к сервису themealdb.com, разместим в отдельном файле api.js.

import {API_URL} from './config';

// получить все категории
const getAllCategories = async () => {
    const response = await fetch(API_URL + 'categories.php');
    return await response.json();
};
// получить рецепты категории
const getCategoryRecipes = async (name) => {
    const response = await fetch(API_URL + 'filter.php?c=' + name);
    return await response.json();
};
// получить один рецепт по id
const getOneRecipe = async (id) => {
    const response = await fetch(API_URL + 'lookup.php?i=' + id);
    return await response.json();
};

export {getAllCategories, getCategoryRecipes, getOneRecipe};

Дорабатываем компонент Catalog

Компонент Catalog должен получить от сервиса themealdb.com список категорий и сохранить их в состоянии. До тех пор, пока список не получен — показывается компонент Preloader.

import {useState, useEffect} from 'react';
import {getAllCategories} from '../api';
import Preloader from '../components/Preloader';
import CategoryList from './CategoryList';

export default function Catalog() {
    const [categories, setCategories] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        getAllCategories().then(data => {
            data.categories && setCategories(data.categories);
            setLoading(false);
        });
    }, []);

    return (
        <>
            <h1>Categories</h1>
            {loading ? (
                <Preloader />
            ) : categories.length ? (
                <CategoryList items={categories} />
            ) : (
                <p>Не удалось загрузить список</p>
            )}
        </>
    );
}

Компоненты для показа списка категорий

Создаем компонент CategoryList, который будет отвечать за показ списка категорий и компонент CategoryCard, который будет отвечать за показ элемента списка.

import CategoryCard from './CategoryCard.js';

export default function CategoryList(props) {
    return (
        <div className="items">
            {props.items.map(item => <CategoryCard key={item.idCategory} {...item} />)}
        </div>
    );
}
import {Link} from 'react-router-dom';

export default function CategoryCard(props) {
    const {
        strCategory,
        strCategoryThumb,
        strCategoryDescription
    } = props;
    return (
        <div className="card">
            <div className="card-image waves-effect waves-block waves-light">
                <img className="activator" src={strCategoryThumb} alt="" />
            </div>
            <div className="card-content">
                <span className="card-title activator grey-text text-darken-4">
                    {strCategory}
                </span>
                <p>{strCategoryDescription.slice(0, 90)}...</p>
            </div>
            <div className="card-action">
                <Link to={`/category/${strCategory}`} className="btn-small">
                    View recipes
                </Link>
            </div>
        </div>
    );
}

Дорабатываем компонент Content

Кроме показа списка категорий нам еще нужно показывать отдельную категорию (компонент CategoryItem) и отдельный рецепт (компонент RecipeItem):

import {Route, Switch} from 'react-router-dom';
import Catalog from '../catalog/Catalog';
import About from '../pages/About';
import Contact from '../pages/Contact';
import NotFound from '../components/NotFound';
import CategoryItem from '../catalog/CategoryItem';
import RecipeItem from '../catalog/RecipeItem';

export default function Content() {
    return (
        <main className="container">
            <Switch>
                <Route exact path="/" component={Catalog} />
                <Route exact path="/about" component={About} />
                <Route exact path="/contact" component={Contact} />
                <Route exact path="/category/:name" component={CategoryItem} />
                <Route exact path="/recipe/:id(\d+)" component={RecipeItem} />
                <Route component={NotFound} />
            </Switch>
        </main>
    );
}

Создаем компонент CategoryItem

Компонент CategoryItem должен получить от сервиса themealdb.com список рецептов категории и сохранить их в состоянии. До тех пор, пока список не получен — показывается компонент Preloader.

import {useState, useEffect} from 'react';
import {useParams, useHistory} from 'react-router-dom';
import {getCategoryRecipes} from '../api';

import Preloader from '../components/Preloader';
import RecipeList from './RecipeList';

export default function CategoryItem(props) {
    const params = useParams();
    const history = useHistory();

    const [recipes, setRecipes] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        getCategoryRecipes(params.name).then(data => {
            data.meals && setRecipes(data.meals);
            setLoading(false);
        });
    }, [params.name]);

    return (
        <>
            {loading ? (
                <Preloader />
            ) : recipes.length ? (
                <>
                    <h1>{params.name}</h1>
                    <button className="btn" onClick={history.goBack}>Go Back</button>
                    <RecipeList items={recipes} />
                </>
            ) : (
                <p>Не удалось загрузить список</p>
            )}
        </>
    );
}

Компоненты для показа списка рецептов

Создаем компонент RecipeList, который будет отвечать за показ списка рецептов категории и компонент RecipeCard, который будет отвечать за показ элемента списка.

import RecipeCard from './RecipeCard.js';

export default function RecipeList(props) {
    return (
        <div className="items">
            {props.items.map(item => <RecipeCard key={item.idMeal} {...item} />)}
        </div>
    );
}
import {Link} from 'react-router-dom';

export default function RecipeCard(props) {
    const {
        idMeal,
        strMeal,
        strMealThumb
    } = props;
    return (
        <div className="card">
            <div className="card-image waves-effect waves-block waves-light">
                <img className="activator" src={strMealThumb} alt="" />
            </div>
            <div className="card-content">
                <span className="card-title activator grey-text text-darken-4">
                    {strMeal}
                </span>
            </div>
            <div className="card-action">
                <Link to={`/recipe/${idMeal}`} className="btn-small">
                    View recipe
                </Link>
            </div>
        </div>
    );
}

Создаем компонент RecipeItem

Компонент RecipeItem должен получить от сервиса themealdb.com рецепт по идентификатору и сохранить его в состоянии. До тех пор, пока рецепт не получен — показывается компонент Preloader.

import {useState, useEffect} from 'react';
import {useParams, useHistory} from 'react-router-dom';
import {getOneRecipe} from '../api';

import Preloader from '../components/Preloader';

export default function RecipeItem(props) {
    const params = useParams();
    const history = useHistory();

    const [recipe, setRecipe] = useState({});
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        getOneRecipe(params.id).then(data => {
            data.meals[0].idMeal && setRecipe(data.meals[0]);
            setLoading(false);
        });
    }, [params.id]);

    return (
        <>
            {loading ? (
                <Preloader />
            ) : recipe.idMeal ? (
                <>
                    <h1>{recipe.strMeal}</h1>
                    <button className="btn" onClick={history.goBack}>Go Back</button>
                    <img src={recipe.strMealThumb} alt="" />
                    <p>Category: {recipe.strCategory}</p>
                    {recipe.strArea !== 'Unknown' && <p>Area: {recipe.strArea}</p>}
                    <p>{recipe.strInstructions}</p>
                </>
            ) : (
                <p>Не удалось загрузить рецепт</p>
            )}
        </>
    );
}

Добавляем описание категории

Плохо, что на странице категории мы не можем показать описание категории. Потому что описание есть в ответе на получение списка категорий, но нет в ответе на получение списка рецептов категории. Давайте будем сохранять список категорий в контексте, чтобы компонент CategoryItem мог из него получить описание категории.

import {createContext, useState, useEffect} from 'react';
import {getAllCategories} from '../api';

const CatalogContext = createContext();

const CatalogContextProvider = (props) => {
    // здесь будем хранить все категории, после того, как их получим
    const [categories, setCategories] = useState([]);
    // при монтрировании будет выполнен запрос к сервису на получение
    // категорий; до того, как категории получены, будем показывать
    // компонент Preloader; после получения изменяем loading на false,
    // чтобы вызвать повторный рендер и показать полученные категории
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        getAllCategories().then(data => {
            data.categories && setCategories(data.categories);
            setLoading(false);
        });
    }, []);

    const getCategory = (name) => categories.find(item => item.strCategory === name);

    const value = {
        categories: categories,
        loading: loading,
        getCategory: getCategory,
    };

    return (
        <CatalogContext.Provider value={value}>
            {props.children}
        </CatalogContext.Provider>
    );
}

export {CatalogContext, CatalogContextProvider};
import {BrowserRouter as Router} from 'react-router-dom';
import Header from './layout/Header';
import Footer from './layout/Footer';
import Content from './layout/Content';
import {CatalogContextProvider} from './catalog/Context';

export default function App() {
    return (
        <Router>
            <Header />
            <CatalogContextProvider>
                <Content />
            </CatalogContextProvider>
            <Footer />
        </Router>
    );
}
import {useState, useEffect, useContext} from 'react';
import {useParams, useHistory} from 'react-router-dom';
import {getCategoryRecipes} from '../api';
import {CatalogContext} from './Context';

import Preloader from '../components/Preloader';
import RecipeList from './RecipeList';

export default function CategoryItem(props) {
    const params = useParams();
    const history = useHistory();

    const [recipes, setRecipes] = useState([]);
    const [loading, setLoading] = useState(true);

    const {
        loading: loadingContext,
        getCategory: getCategoryContext
    } = useContext(CatalogContext);

    useEffect(() => {
        getCategoryRecipes(params.name).then(data => {
            data.meals && setRecipes(data.meals);
            setLoading(false);
        });
    }, [params.name]);

    const category = getCategoryContext(params.name);

    return (
        <>
            {loading || loadingContext ? (
                <Preloader />
            ) : recipes.length ? (
                <>
                    <h1>{params.name}</h1>
                    <button className="btn" onClick={history.goBack}>Go Back</button>
                    {category && <p>{category.strCategoryDescription}</p>}
                    <RecipeList items={recipes} />
                </>
            ) : (
                <p>Не удалось загрузить список</p>
            )}
        </>
    );
}

Кэшируем результаты запросов

Мы можем сохранять в контексте не только список категорий, но и список рецептов категории — после того, как получим этот список от сервиса themealdb.com. Если пользователь просмотрел список рецептов трех категорий, а потом решил просмотреть эти списки повторно — нам не нужно будет еще раз обращаться к сервису, чтобы получить эти три списка.

import {createContext, useState, useEffect} from 'react';
import {getAllCategories} from '../api';

const CatalogContext = createContext();

const CatalogContextProvider = (props) => {
    const [categories, setCategories] = useState([]);
    const [loading, setLoading] = useState(true);
    // рецепты каждой категории, которую пользователь уже просмотрел;
    // как только рецепты категории получены — сохраняем их здесь
    const recipes = {};

    useEffect(() => {
        getAllCategories().then(data => {
            data.categories && setCategories(data.categories);
            setLoading(false);
        });
    }, []);

    const getCategory = (name) => categories.find(item => item.strCategory === name);

    const getRecipes = (name) => {
        return recipes[name] ? recipes[name] : [];
    };

    const setRecipes = (name, items) => {
       recipes[name] = items;
    };

    const value = {
        categories: categories,
        loading: loading,
        getCategory: getCategory,
        getRecipes: getRecipes,
        setRecipes: setRecipes,
    };

    return (
        <CatalogContext.Provider value={value}>
            {props.children}
        </CatalogContext.Provider>
    );
}

export {CatalogContext, CatalogContextProvider};
import {useContext, useState, useEffect} from 'react';
import {useParams, useHistory} from 'react-router-dom';
import {CatalogContext} from './Context';
import {getCategoryRecipes} from '../api';

import Preloader from '../components/Preloader';
import RecipeList from './RecipeList';

export default function CategoryItem(props) {
    const params = useParams();
    const history = useHistory();

    const [recipes, setRecipes] = useState([]);
    const [loading, setLoading] = useState(true);

    const {
        loading: loadingContext,
        getCategory: getCategoryContext,
        getRecipes: getRecipesContext,
        setRecipes: setRecipesContext
    } = useContext(CatalogContext);

    useEffect(() => {
        const saved = getRecipesContext(params.name);
        if (saved.length === 0) {
            getCategoryRecipes(params.name).then(data => {
                if (data.meals) {
                    setRecipes(data.meals);
                    // сохраняем рецепты категории в контекст
                    setRecipesContext(params.name, data.meals);
                }
                setLoading(false);
            });
        } else {
            setRecipes(saved);
            setLoading(false);
        }
    }, []);

    const category = getCategoryContext(params.name);

    return (
        <>
            {loading || loadingContext ? (
                <Preloader />
            ) : recipes.length ? (
                <>
                    <h1>{params.name}</h1>
                    <button className="btn" onClick={history.goBack}>Go Back</button>
                    {category && <p>{category.strCategoryDescription}</p>}
                    <RecipeList items={recipes} />
                </>
            ) : (
                <p>Не удалось загрузить список</p>
            )}
        </>
    );
}

В принципе, мы могли пойти дальше и хранить в контексте подробную информацию о рецептах, которые пользователь уже просмотрел — на тот случай, если он захочет просмотреть эти рецепты еще раз.

Здесь у меня не слишком удачно получилось. Если пользователь начнет просмотр сайта не с главной страницы, а со страницы отдельного рецепта или со страниц About и Contact — компонент CatalogContextProvider отработает и выполнит запрос к сервису themealdb.com на получение категорий. Хотя для просмотра этих страниц категории не нужны, они требуются только для главной страницы и для страницы списка рецептов категории. С другой стороны, если пользователь не ограничится просмотром одного рецепта или одной статичной страницы — список категорий обязательно потребуется.

Поиск рецептов

Сервис themealdb.com позволяет искать рецепты по названию — давайте реализуем такую возможность для нашего приложения. Первым делом добавим еще одну функцию в api.js.

import {API_URL} from './config';

/* .......... */

const getFoundRecipes = async (seacrh) => {
    seacrh = encodeURIComponent(seacrh);
    const response = await fetch(API_URL + 'search.php?s=' + seacrh);
    return await response.json();
}

export {getAllCategories, getCategoryRecipes, getOneRecipe, getFoundRecipes};

Компонент SearchInput для ввода поискового запроса:

import {useState, useEffect} from 'react';
import {useLocation} from 'react-router';

export default function SearchInput(props) {
    const location = useLocation();

    const [value, setValue] = useState('');

    useEffect(() => {
        const search = decodeURIComponent(location.search);
        if (search === '') {
            setValue('');
            return;
        }
        const [name, input] = search.split('=');
        if (name.trim() !== '?str') {
            setValue('');
            return;
        }
        if (input.trim() === '') {
            setValue('');
            return;
        }
        setValue(input.trim());
    }, [location.search]);

    const handleEnter = (event) => {
        if (event.key === 'Enter') {
            props.searchHandler(value);
        }
    }

    return (
        <div className="row">
            <div className="input-field col s12">
                <input
                    type="text"
                    value={value}
                    onChange={event => setValue(event.target.value)}
                    onKeyUp={handleEnter}
                    placeholder="Search recipes, for example — Arrabiata"
                />
                <button
                    className="btn search"
                    onClick={() => props.searchHandler(value)}>
                    Search
                </button>
            </div>
        </div>
    );
}

Это управляемый компонент. Введенный пользователем поисковый запрос сохраняется в состоянии. При нажатии клавиши Enter или кнопки — вызывается функция searchHandler, которую компонент получает через пропсы (и которую мы реализуем ниже). Хук useLocation позволяет получить доступ к строке поискового запроса ?str=Beef, чтобы задавать значение атрибута value элемента <input>.

Компонент SearchResult, который выполняет запрос к сервису и показывает результаты:

import {useState, useEffect} from 'react';
import {useLocation} from 'react-router-dom';
import {getFoundRecipes} from '../api';
import Preloader from '../components/Preloader';
import RecipeList from './RecipeList';

export default function SearchResult(props) {
    const location = useLocation();

    const [recipes, setRecipes] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        // проверяем корректность данных
        const search = decodeURIComponent(location.search);
        if (search === '') {
            setLoading(false);
            return;
        }
        const [name, value] = search.split('=');
        if (name.trim() !== '?str') {
            setLoading(false);
            return;
        }
        if (value.trim() === '') {
            setLoading(false);
            return;
        }
        // выполняем запрос к сервису
        getFoundRecipes(value).then(data => {
            if (data.meals) {
                setRecipes(data.meals);
            }
            setLoading(false);
        });
    }, [location.search]);

    return (
        <>
            {loading ? (
                <Preloader />
            ) : recipes.length ? (
                <>
                    <h1>Search results</h1>
                    <RecipeList items={recipes} />
                </>
            ) : (
                <p>Ничего не найдено</p>
            )}
        </>
    );
}

Хук useLocation позволяет получить доступ к строке поискового запроса ?str=Beef, чтобы извлечь значение Beef и выполнить запрос к сервису themealdb.com. Перед этим мы проводим дополнительные проверки, что location.search не равен пустой строке и т.п.

Изменяем компонент Content, чтобы он обрабатывал нажатие клавиши Enter — при этом в историю браузера добавляется еще одна страница /search?str=Beef. Обрабатывет эту страницу компонент SearchResult — потому что есть новый <Route> внутри <Switch>.

import {Route, Switch} from 'react-router-dom';
import Catalog from '../catalog/Catalog';
import About from '../pages/About';
import Contact from '../pages/Contact';
import NotFound from '../components/NotFound';
import CategoryItem from '../catalog/CategoryItem';
import RecipeItem from '../catalog/RecipeItem';
import SearchInput from '../catalog/SearchInput';
import {useHistory} from 'react-router-dom';
import SearchResult from '../catalog/SearchResult';

export default function Content() {
    const history = useHistory();

    // если пользователь ввел поисковый запрос и нажал Enter,
    // то переходим на страницу server.com/search?str=Beaf и
    // добавляем ее в историю посещенных страниц браузера
    const handleSearch = (str) => {
        if (str.trim() === "") return;
        history.push({
            pathname: '/search',
            search: `?str=${str.trim()}`,
        });
    };

    return (
        <main className="container">
            <SearchInput searchHandler={handleSearch} />
            <Switch>
                <Route exact path="/" component={Catalog} />
                <Route exact path="/about" component={About} />
                <Route exact path="/contact" component={Contact} />
                <Route exact path="/category/:name" component={CategoryItem} />
                <Route exact path="/recipe/:id(\d+)" component={RecipeItem} />
                <Route exact path="/search" component={SearchResult} />
                <Route component={NotFound} />
            </Switch>
        </main>
    );
}

Исходные коды здесь, демо-сайт здесь.

Поиск: API • JavaScript • Web-разработка • Frontend • Компонент • Практика

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.