React.js. Приложение для поиска фильмов

02.08.2021

Теги: FrontendJavaScriptReact.jsWeb-разработкаКомпонентПоискПрактика

Небольшое приложение для поиска фильмов, не имеет практической ценности, сделано исключительно с целью изучения React. Для оформления используем css-фреймворк materialize.css, http-запросы будем отправлять к сервису omdbapi.com. Чтобы отправлять запросы на поиск фильмов — нужно получить api-ключ, это бесплатно. Итак, разворачиваем 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 {
    /*
    может как увеличиваться, так и уменьшаться, чтобы вместе с
    <header> и <footer> занять всю высоту viewport браузера
    */
    flex: 1 1 auto;
}
import Header from './layout/Header';
import Footer from './layout/Footer';
import Main from './layout/Main';

function App() {
    return (
        <>
            <Header />
            <Main />
            <Footer />
        </>
    );
}

export default App;

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

function Header() {
    return (
        <header>
            <nav className="light-green darken-3">
                <div className="nav-wrapper container">
                    <a href="#" className="brand-logo">Movies search</a>
                    <ul id="nav-mobile" className="right hide-on-med-and-down">
                        <li><a href="#">Link one</a></li>
                        <li><a href="#">Link two</a></li>
                    </ul>
                </div>
            </nav>
        </header>
    );
}

export default Header;
function Footer() {
    return (
        <footer className="page-footer light-green darken-3">
            <div className="container">
                © {new Date().getFullYear()} All rights reserved
            </div>
        </footer>
    );
}

export default Footer;
function Main() {
    return (
        <main className="container">
            Основной контент приложения
        </main>
    );
}

export default Main;

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

<!DOCTYPE html>
<html lang="en">
    <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="Search for movies and serials" />
        <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" />
        <title>Movies search</title>
    </head>
    <body>
        <noscript>You need to enable JavaScript to run this app.</noscript>
        <div id="root"></div>
    </body>
</html>

Работа с сервисом omdbapi

Для изучения API потребуется приложение Postman, его можно скачать бесплатно. Вот пример поиска фильма по слову «matrix»:

http://www.omdbapi.com/?apikey=your-api-key&s=matrix
{
    "Search":
        [
            {
                    "Title":"The Matrix",
                    "Year":"1999",
                    "imdbID":"tt0133093",
                    "Type":"movie",
                    "Poster":"https://m.media-amazon.com/images/M/MV5BN.jpg"
            },
            {
                    "Title":"The Matrix Reloaded",
                    "Year":"2003",
                    "imdbID":"tt0234215",
                    "Type":"movie",
                    "Poster":"https://m.media-amazon.com/images/M/MV5BD.jpg"
            },
            {
                "Title":"The Matrix Revolutions",
                "Year":"2003",
                "imdbID":"tt0242653",
                "Type":"movie",
                "Poster":"https://m.media-amazon.com/images/M/MV5FZ.jpg"
            },
            {
                "Title":"The Matrix Revisited",
                "Year":"2001",
                "imdbID":"tt0295432",
                "Type":"movie",
                "Poster":"https://m.media-amazon.com/images/M/MV5BM.jpg"
            },
            {
                "Title":"Enter the Matrix",
                "Year":"2003",
                "imdbID":"tt0277828",
                "Type":"game",
                "Poster":"https://m.media-amazon.com/images/M/MV5SN.jpg"
            },
            {
                "Title":"The Matrix: Path of Neo"
                ,"Year":"2005",
                "imdbID":"tt0451118",
                "Type":"game",
                "Poster":"https://m.media-amazon.com/images/M/MV5BZ.jpg"
            },
            {
                "Title":"A Glitch in the Matrix",
                "Year":"2021",
                "imdbID":"tt9847360",
                "Type":"movie",
                "Poster":"https://m.media-amazon.com/images/M/MV5BFX.jpg"
            },
            {
                "Title":"Armitage III: Dual Matrix",
                "Year":"2002",
                "imdbID":"tt0303678",
                "Type":"movie",
                "Poster":"https://m.media-amazon.com/images/M/MV5LK.jpg"
            },
            {
                "Title":"CR: Enter the Matrix",
                "Year":"2009",
                "imdbID":"tt1675286",
                "Type":"game",
                "Poster":"https://m.media-amazon.com/images/M/MV5RQ.jpg"
            },
            {
                "Title":"Sex and the Matrix",
                "Year":"2000",
                "imdbID":"tt0274085",
                "Type":"movie",
                "Poster":"N/A"
            }
        ],
        "totalResults":"114",
        "Response":"True"
}

Запрос подробной информации о фильме по идентификатору imdbID:

http://www.omdbapi.com/?apikey=your-api-key&i=tt0133093&plot=full
{
    "Title": "The Matrix",
    "Year": "1999",
    "Rated": "R",
    "Released": "31 Mar 1999",
    "Runtime": "136 min",
    "Genre": "Action, Sci-Fi",
    "Director": "Lana Wachowski, Lilly Wachowski",
    "Writer": "Lilly Wachowski, Lana Wachowski",
    "Actors": "Keanu Reeves, Laurence Fishburne, Carrie-Anne Moss",
    "Plot": "Thomas A. Anderson is a man living two lives. By day he is an average computer programmer...",
    "Language": "English",
    "Country": "United States, Australia",
    "Awards": "Won 4 Oscars. 42 wins & 51 nominations total",
    "Poster": "https://m.media-amazon.com/images/M/MV5BN.jpg",
    "Ratings": [
        {
            "Source": "Internet Movie Database",
            "Value": "8.7/10"
        },
        {
            "Source": "Rotten Tomatoes",
            "Value": "88%"
        },
        {
            "Source": "Metacritic",
            "Value": "73/100"
        }
    ],
    "Metascore": "73",
    "imdbRating": "8.7",
    "imdbVotes": "1,721,603",
    "imdbID": "tt0133093",
    "Type": "movie",
    "DVD": "01 Jan 2009",
    "BoxOffice": "$171,479,930",
    "Production": "Village Roadshow Prod., Silver Pictures",
    "Website": "N/A",
    "Response": "True"
}

Создание компонентов

Создадим директорию components и разместим в ней два компонента — Movies.js и Card.js.

import Card from './Card'

function Movies(props) {
    return (
        <div className="movies">
            {props.movies.map(movie => 
                <Card key={movie.imdbID} {...movie} />
            )}
        </div>
    );
}

export default Movies;
function Card(props) {
    const {
        Title,
        Year,
        imdbID,
        Type,
        Poster
    } = props;
    return (
        <div id={"movie-" + imdbID} className="card">
            <div className="card-image waves-effect waves-block waves-light">
                <img className="activator" src={Poster} alt="" />
            </div>
            <div className="card-content">
                <span className="card-title activator grey-text text-darken-4">
                    {Title}
                </span>
                <p>
                    <span>{Year}, {Type}</span>
                    <a href="#" className="right">Read more</a>
                </p>
            </div>
        </div>
    );
}

export default Card;

Компонент Main.js переделаем с функционального на классовый:

import React from 'react';
import Movies from '../components/Movies';

class Main extends React.Component {
    state = {
        movies: []
    }

    componentDidMount() {
        fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix')
            .then(response => response.json())
            .then(data => this.setState({movies: data.Search}));
    }

    render() {
        return (
            <main className="container">
                {this.state.movies.length ? (
                    <Movies movies={this.state.movies} />
                ) : (
                    <div className="loader">Loading...</div>
                )}
            </main>
        );
    }
}

export default Main;

И добавим стили, чтобы карточки фильмов красиво выстраивались по сетке:

.movies {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 20px;
}

Хорошо, список фильмов загружается, теперь нужно поле <input> для ввода поискового запроса. Как нетрудно догадаться, мы его оформим в виде компонента Search.js. И добавим новый компонент в метод render() компонента Main.

import React from 'react';

class Search extends React.Component {
    state = {
        search: "",
    }

    render() {
        return (
            <div className="row">
                <div className="input-field col s12">
                    <input
                        type="text"
                        value={this.state.search}
                        onChange={event => this.setState({search: event.target.value})}
                        placeholder="For example — matrix"
                    />
                </div>
            </div>
        );
    }
}

export default Search;
class Main extends React.Component {
    /* ..... */
    render() {
        return (
            <main className="container">
                <Search />
                {this.state.movies.length ? (
                    <Movies movies={this.state.movies} />
                ) : (
                    <div className="loader">Loading...</div>
                )}
            </main>
        );
    }
}

Когда пользователь набирает поисковый запрос и нажимает клавишу Enter — мы должны передать строку запроса наверх, в компонент Main. А компонент Main при получении строки запроса — выполнить запрос к сервису и показать список найденных фильмов. Чтобы передать введенный запрос наверх — нам нужна callback-функция.

class Main extends React.Component {
    /* ..... */
    handleEnter = (search) => {
        if (search.trim() === "") return;
        search = encodeURIComponent(search);
        fetch(`http://www.omdbapi.com/?apikey=your-api-key&s=${search}`)
            .then(response => response.json())
            .then(data => this.setState({movies: data.Search}));
    }
    /* ..... */
    render() {
        return (
            <main className="container">
                <Search enterHandler={this.handleEnter} />
                {this.state.movies.length ? (
                    <Movies movies={this.state.movies} />
                ) : (
                    <div className="loader">Loading...</div>
                )}
            </main>
        );
    }
}
class Search extends React.Component {
    state = {
        search: "",
    }

    handleEnter = (event) => {
        if (event.key === 'Enter') {
            this.props.enterHandler(this.state.search);
        }
    }

    render() {
        return (
            <div className="row">
                <div className="input-field col s12">
                    <input
                        type="text"
                        value={this.state.search}
                        onChange={event => this.setState({search: event.target.value})}
                        onKeyUp={this.handleEnter}
                        placeholder="For example — matrix"
                    />
                    <button
                        className="btn"
                        onClick={() => this.props.enterHandler(this.state.search)}>
                        Search
                    </button>
                </div>
            </div>
        );
    }
}

Для удобства пользователей добавлена кнопка Search, которая делает то же самое, что и нажатие клавиши Enter.

Фильтрация результатов

Собственно, на этом можно было бы и закончить, но давайте еще добавим фильтрацию результатов поиска, чтобы можно было искать только фильмы или только сериалы. Сервис omdbapi.com позволяет отправлять дополнительный параметр type, который может принимать значения movie или series.

import React from 'react';

class Search extends React.Component {
    state = {
        search: '',
        type: 'all',
    }

    handleEnter = (event) => {
        if (event.key === 'Enter') {
            this.props.enterHandler(this.state.search, this.state.type);
        }
    }

    handleFilter = (event) => {
        this.setState({type: event.target.value});
    }

    render() {
        return (
            <div className="row">
                <div className="input-field col s12">
                    <input
                        type="text"
                        value={this.state.search}
                        onChange={event => this.setState({search: event.target.value})}
                        onKeyUp={this.handleEnter}
                        placeholder="For example — matrix"
                    />
                    <button
                        className="btn"
                        onClick={() => this.props.enterHandler(this.state.search, this.state.type)}>
                        Search
                    </button>
                </div>
                <p>
                    <label>
                        <input
                            type="radio"
                            name="type"
                            value="all"
                            onChange={this.handleFilter}
                            checked={this.state.type === "all"}
                            className="with-gap"
                        />
                        <span>All</span>
                    </label>
                    <label>
                        <input
                            type="radio"
                            name="type"
                            value="movie"
                            onChange={this.handleFilter}
                            checked={this.state.type === "movie"}
                            className="with-gap"
                        />
                        <span>Movies only</span>
                    </label>
                    <label>
                        <input
                            type="radio"
                            name="type"
                            value="series"
                            onChange={this.handleFilter}
                            checked={this.state.type === "series"}
                            className="with-gap"
                        />
                        <span>Series only</span>
                    </label>
                </p>
            </div>
        );
    }
}

export default Search;

Теперь мы будем передавать в метод handleEnter компонента Main не один, а два аргумента:

class Main extends React.Component {
    /* ..... */
    handleEnter = (search, type) => {
        if (search.trim() === "") return;
        search = encodeURIComponent(search);
        let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`;
        if (type !== 'all') {
            url = url + `&type=${type}`;
        }
        fetch(url)
            .then(response => response.json())
            .then(data => this.setState({movies: data.Search}));
    }
    /* ..... */
}

Запрос при изменении type

Когда пользователь выполнил поиск фильмов и сериалов и хочет отфильтровать результаты — ничего не происходит. После выбора нужной радио-кнопки нужно еще раз нажать клавишу Search. Хорошо, если бы при выборе фильтра — выполнялся запрос на сервер с новым значением type и показывались только фильмы или только сериалы.

class Search extends React.Component {
    /* ...... */
    handleFilter = (event) => {
        this.setState({type: event.target.value});
        this.props.enterHandler(this.state.search, this.state.type);
    }
    /* ..... */
}

Но тут меня поджидал неприятный сюрприз — все работало совсем не так, как ожидалось. Проблема оказалась в том, что нельзя сразу после вызова setState() использовать новое значение состояния. В документации написано — воспринимайте setState() как запрос на обновление состояния, а не как команду немедленного обновления. В результате, на сервер отправлялось старое значение type и результаты казались странными: вместо фильмов — сериалы, вместо сериалов — фильмы.

class Search extends React.Component {
    /* ...... */
    handleFilter = (event) => {
        this.setState(
            {type: event.target.value},
            () => this.props.enterHandler(this.state.search, this.state.type)
        );
    }
    /* ..... */
}

Метод setState() в качестве второго аргумента может принимать callback-функцию, которая будет вызвана после того, как состояние изменится.

Если ничего не найдено

Если ничего найти не удалось, у нас вылезает ошибка «TypeError: Cannot read property length of undefined». Если посмотреть ответ сервера, то понятно — почему это происходит. В ответе просто нет поля Search — и мы получаем undefined, когда к нему обращаемся.

{
    "Response":"False",
    "Error":"Movie not found!"
}

Давайте это исправим. Добавим в state компонента Main еще одно свойство loading. Когда пользователь нажимает Enter, оно принимает значение true, когда получен ответ от сервера — false. А метод render() в зависимости от значения loading будет показывать либо список фильмов, либо прелоадер.

class Main extends React.Component {
    state = {
        movies: [],
        loading: true,
    }

    handleEnter = (search, type) => {
        if (search.trim() === "") return;
        this.setState({loading: true})
        search = encodeURIComponent(search);
        let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`;
        if (type !== 'all') {
            url = url + `&type=${type}`;
        }
        fetch(url)
            .then(response => response.json())
            .then(data => {
                this.setState({
                    movies: data.Search ? data.Search : [], 
                    loading: false
                });
            });
    }

    componentDidMount() {
        fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix')
            .then(response => response.json())
            .then(data => {
                this.setState({
                    movies: data.Search ? data.Search : [],
                    loading: false
                });
            });
    }

    render() {
        return (
            <main className="container">
                <Search enterHandler={this.handleEnter} />
                {this.state.loading ? (
                    <div className="loader">Loading...</div>
                ) : (
                    <Movies movies={this.state.movies} />
                )}
            </main>
        );
    }
}

Если json-ответ от сервера не содержит поля Search с результатами поиска — значит, по запросу ничего не найдено. И мы присваиваем свойству movies объекта state пустой массив вместо undefined. Осталось только в компоненте Movies обработать эту ситуацию.

function Movies(props) {
    return (
        <div className="movies">
            {props.movies.length ? (
                props.movies.map(movie => 
                    <Card key={movie.imdbID} {...movie} />
                )
            ) : (
                <p>Nothing found</p>
            )}
        </div>
    );
}

Подробная информация о фильме

В списке найденных фильмов на каждой карточке есть ссылка «Read more», при клике на нее нужно выполнить запрос к сервису omdbapi для получения подробной информации о фильме. В момент клика нужно передать идентификатор фильма из компонента Card в компонент Main через callback-функцию. Чтобы понимать, что нужно показывать пользователю в данный момент — добавим свойство show в объект state компонента Main.

class Main extends React.Component {
    state = {
        show: 'index', // index, search, movie
        movies: [], // массив найденных фильмов
        movie: {}, // информация о фильме
        loading: true, // идет запрос к серверу?
    }

    // пользователь набрал поисковый запрос и нажал Enter
    handleEnter = (search, type) => {
        if (search.trim() === "") return;
        this.setState({
            loading: true,
            show: 'search'
        });
        search = encodeURIComponent(search);
        let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`;
        if (type !== 'all') {
            url = url + `&type=${type}`;
        }
        fetch(url)
            .then(response => response.json())
            .then(data => {
                this.setState({
                    movies: data.Search ? data.Search : [], 
                    loading: false
                });
            });
    }

    // пользователь кликнул ссылке по «Read more» для просмотра
    // подробной информации по найденному фильму или сериалу
    handleReadMore = (id) => {
        this.setState({
            loading: true,
            show: 'movie'
        });
        fetch(`http://www.omdbapi.com/?apikey=your-api-key&i=${id}&plot=full`)
            .then(response => response.json())
            .then(data => {
                this.setState({
                    movie: data.Title ? data : {},
                    loading: false
                });
            });
    }

    componentDidMount() {
        fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix')
            .then(response => response.json())
            .then(data => {
                this.setState({
                    movies: data.Search ? data.Search : [],
                    loading: false
                });
            });
    }

    render() {
        return (
            <main className="container">
                <Search enterHandler={this.handleEnter} />
                {this.state.loading ? (
                    <div className="loader">Загрузка...</div>
                ) : this.state.show === 'movie' ? (
                    <Movie {...this.state.movie} />
                ) : (
                    <Movies
                        movies={this.state.movies}
                        readMoreHandler={this.handleReadMore}
                    />
                )}
            </main>
        );
    }
}

Функцию handleReadMore() мы должны спустить вниз через пропсы компоненту Movies, а компонент Movies — спустить дальше, компоненту Card. А компонент Card должен вызвать эту callback-функцию в момент клика по ссылке «Read more».

function Movies(props) {
    return (
        <div className="movies">
            {props.movies.length ? (
                props.movies.map(movie => 
                    <Card
                        key={movie.imdbID}
                        readMoreHandler={props.readMoreHandler}
                        {...movie}
                    />
                )
            ) : (
                <p>Nothing found</p>
            )}
        </div>
    );
}
function Card(props) {
    const {
        Title,
        Year,
        imdbID,
        Type,
        Poster
    } = props;
    const text = Title.replace(/^a-z0-9 /i, '').replace(/\s/, '+');
    return (
        <div id={"movie-" + imdbID} className="card">
            <div className="card-image waves-effect waves-block waves-light">
                {Poster !== 'N/A' ? (
                    <img
                        className="activator"
                        src={Poster}
                        alt=""
                    />
                ) : (
                    <img
                        className="activator"
                        src={`https://via.placeholder.com/300x430.png?text=${text}`}
                        alt=""
                    />
                )}
            </div>
            <div className="card-content">
                <span className="card-title activator grey-text text-darken-4">
                    {Title}
                </span>
                <p>
                    <span>{Year}, {Type}</span>
                    <a
                        href="#"
                        className="right"
                        onClick={(event) => {
                            event.preventDefault(); props.readMoreHandler(imdbID)
                        }}
                    >Read more</a>
                </p>
            </div>
        </div>
    );
}

За показ информации об отдельном фильме будет отвечать компонент Movie:

function Movie(props) {
    if (!props.Title) {
        return <p>Movie not found</p>
    }
    const {
        Title,
        Year,
        Runtime,
        Genre,
        Actors,
        Plot,
        Poster
    } = props;
    const text = Title.replace(/^a-z0-9 /i, '').replace(/\s/, '+');
    return (
        <div className="row">
            <div className="col s12">
                <h1 style={{marginTop: 0}}>{Title}</h1>
            </div>
            <div className="col s5">
                {Poster !== 'N/A' ? (
                    <img
                        className="responsive-img"
                        src={Poster}
                        alt=""
                    />
                ) : (
                    <img
                        className="responsive-img"
                        src={`https://via.placeholder.com/300x430.png?text=${text}`}
                        alt=""
                    />
                )}
            </div>
            <div className="col s7">
                <ul style={{marginTop: 0}}>
                    <li>Year: {Year}</li>
                    <li>Runtime: {Runtime}</li>
                    <li>Genre: {Genre}</li>
                    <li>Actors: {Actors}</li>
                </ul>
                <p>{Plot}</p>
            </div>
        </div>
    );
}

Вместо заключения

Сейчас изучаю хуки, так что переписал два классовых компонента Main и Search на функциональные.

import React, {useState, useEffect} from 'react';
import Movies from '../components/Movies';
import Search from '../components/Search';
import Movie from '../components/Movie';

function Main() {
    const [show, setShow] = useState('index');
    const [movies, setMovies] = useState([]);
    const [movie, setMovie] = useState({});
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix')
            .then(response => response.json())
            .then(data => {
                setMovies(data.Search ? data.Search : []);
                setLoading(false);
            });
    }, []); // сработает только при монтировании

    // пользователь набрал поисковый запрос и нажал Enter
    const handleEnter = (search, type) => {
        if (search.trim() === "") return;
        setLoading(true);
        setShow('search');
        search = encodeURIComponent(search);
        let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`;
        if (type !== 'all') {
            url = url + `&type=${type}`;
        }
        fetch(url)
            .then(response => response.json())
            .then(data => {
                setMovies(data.Search ? data.Search : []);
                setLoading(false);
            });
    }

    // пользователь кликнул ссылке по «Read more» для просмотра
    // подробной информации по найденному фильму или сериалу
    const handleReadMore = (id) => {
        setLoading(true);
        setShow('movie');
        fetch(`http://www.omdbapi.com/?apikey=your-api-key&i=${id}&plot=full`)
            .then(response => response.json())
            .then(data => {
                setMovie(data.Title ? data : {});
                setLoading(false);
            });
    }

    return (
        <main className="container">
            <Search enterHandler={handleEnter} />
            {loading ? (
                <div className="loader">Загрузка...</div>
            ) : show === 'movie' ? (
                <Movie {...movie} />
            ) : (
                <Movies
                    movies={movies}
                    readMoreHandler={handleReadMore}
                />
            )}
        </main>
    );
}

export default Main;
import React, {useState} from 'react';

function Search(props) {
    const [search, setSearch] = useState('');
    const [type, setType] = useState('all');

    const handleEnter = (event) => {
        if (event.key === 'Enter') {
            props.enterHandler(search, type);
        }
    }

    const handleFilter = (event) => {
        setType(event.target.value);
        // здесь мы не можем передать вторым аргументом type, потому что значение
        // type не изменяется мгновенно, а callback-функция, как в setState() для
        // классового компонента, здесь не предусмотрена
        props.enterHandler(search, event.target.value);
    }

    return (
        <div className="row">
            <div className="input-field col s12">
                <input
                    type="text"
                    value={search}
                    onChange={event => setSearch(event.target.value)}
                    onKeyUp={handleEnter}
                    placeholder="For example — matrix"
                />
                <button
                    className="btn"
                    onClick={() => props.enterHandler(search, type)}>
                    Search
                </button>
            </div>
            <p>
                <label>
                    <input
                        type="radio"
                        name="type"
                        value="all"
                        onChange={handleFilter}
                        checked={type === "all"}
                        className="with-gap"
                    />
                    <span>All</span>
                </label>
                <label>
                    <input
                        type="radio"
                        name="type"
                        value="movie"
                        onChange={handleFilter}
                        checked={type === "movie"}
                        className="with-gap"
                    />
                    <span>Movies only</span>
                </label>
                <label>
                    <input
                        type="radio"
                        name="type"
                        value="series"
                        onChange={handleFilter}
                        checked={type === "series"}
                        className="with-gap"
                    />
                    <span>Series only</span>
                </label>
            </p>
        </div>
    );
}

export default Search;

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

Поиск: JavaScript • React.js • 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.