Практика: SearchInput

Задача реализовать компонент SearchInput.
 
Функциональные требования:
  • можно вводить значения для запроса и будет показан список
  • При состоянии загрузки данных показывается loader
  • При состоянии ошибки показывается сообщение об ошибке
 
Нефункциональные требования:
  • Стилизация не имеет значения
  • Нельзя использовать библиотеки (axios, react-query)
 
API:
https://api.github.com/search/users?q=10
 
 
🎯 Зачем спрашивают
  • Проверить умение кандидата декомпозировать задачу
  • Умение работать с HTTP запросами без побочных библиотек и знания нюансов (обработка ошибок)
 
📝 Ответ
💡
Совет: поинтересуйтесь чем можно пользоваться
Как правило, на интервью запрещают пользоваться готовыми решениями (axios, react-query). Поэтому рассмотрим случай, когда нам нужно реализовать все самим. В начале интервью узнайте, разрешено ли вам пользоваться библиотеками. Если разрешат, например, react-query, то задачу можно выполнить быстрее, сфокусировавшись на самой фиче.
Сперва декомпозируйте задачу и прикиньте:
  • какой будет поток данных/управления
  • какое кол-во компонентов вам потребуется
  • какая будет структура
  • какие понадобятся утилиты
 
💡
Данный тип задач построен по принципу прогрессивных улучшений
Несмотря на кажущуюся простоту, задача таит множество подводных камней, с которыми вы могли бы столкнуть в практике. И “идеальное” решение гораздо более навороченное, нежели базовая его версия. В зависимости от того, как быстро кандидат справляется с задачей, интервьюер может докидывать дополнительные задачи, усложняя и усложняя. Поэтому держите в голове вопросы/задачки, которые вам могут потенциально докинуть интервьюер. Помните: следует озвучивать все, что вы надумали/спланировали. Ведь если вы не успеете реализовать желаемое (тайминги ограничены), то велик шанс того, что получите плюсик от собеседующего за упоминание решения/варианта решения.
Например, в данной задаче допом могут накинуть требование по оптимизации исходящих запросов. То есть не отправлять запрос на каждый введенный символ. Озвучьте необходимость реализовать это сразу. А вот успеете или нет по факту — это уже другой вопрос.
 
Предлагаю следующую структуру:
flowchart TD GUS[GithubUsersSearch] SI[SearchInput] RL[ResultsList] GUS -->|"query, setQuery"| SI GUS -->|"items_data"| RL
Аргументы:
  • Отделение логики от UI. Дает переиспользуемость (SearchInput , ResultsList) и обособляет целую фичу — GithubUsersSearch
  • Проще контролировать состояния загрузки, ошибки и готовых для отображения данных. Если логику хранения и обработку query хранить в SearchInput, то столкнетесь с проблемой “поднятия” состояния.
 
Предлагаю следующую структуру:
/src ├─ features/ │ └─ GithubUsersSearch/ ├─ components/ │ ├─ SearchInput/ │ └─ ResultsList/ ├─ api.ts └─ hooks.ts
 
Начнем реализацию с API:
import { GithubSearchResponse } from './types'; const BASE_URL = 'https://api.github.com'; export async function searchGithubUsers( query: string, signal?: AbortSignal ): Promise<GithubSearchResponse> { const q = query.trim(); // Базовая валидация, чтобы не слать запрос при пустом query if (!q) { return { total_count: 0, items: [] }; } const url = new URL(`${BASE_URL}/search/users`); url.searchParams.set('q', q); url.searchParams.set('per_page', '10'); const res = await fetch(url.toString(), { method: 'GET', headers: {}, // Оставляем на будущее возможность реализовать функционал отмены запросов signal, }); // fetch не выбрасывает исключение для HTTP-статусов вроде 400-ых, 500-ых. // Для fetch «ошибка» — это только: // - проблема на сетевом уровне (нет соединения, разрыв, CORS-блокировка, таймаут через AbortController и т.п.); // - или если сам вызов API был некорректным (например, неправильный URL). if (!res.ok) { throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); } return res.json(); }
 
Отлично, теперь мы можем вызывать API. Далее есть два пути:
  1. написать логику вызова API напрямую в компоненте
  1. сделать абстракцию в виде хука
Агрументы выбрать вариант с абстракцией:
✅ Плюсы
❌ Минусы
Запрос прямо в компоненте
Если время на собесе ограничено и поджимает, этот вариант проще
Засоряет код всего компонента
Абстракция-хук
Переиспользуемое решение → мастабируемость Убирает утилитарный код из фичи
Потребуется немного больше времени, чтобы вынести в отдельный хук
 
Хук для запросов
💡
Если на собеседовании вам разрешили воспользоваться библиотекой react-query (или похожей), то данный шаг является опциональным.
 
Так как время на реализацию ограничено, то сделаем лишь необходимый функционал. Хук должен возвращать состояние данных:
  • успех (сами данные)
  • состояние ошибки
  • состояние загрузки.
Начнем с утилитарного хука, позволяющего делать отложенные реакции на изменение state. Это преждевременная оптимизация, но хуже от этого не будет (один фиг попросят реализовать позже).
import { useEffect, useState } from 'react'; export function useDebouncedValue<T>(value: T, delay = 350) { const [debounced, setDebounced] = useState<T>(value); useEffect(() => { const id = window.setTimeout(() => setDebounced(value), delay); return () => window.clearTimeout(id); }, [value, delay]); return debounced; }
Далее реализуем сам хук:
import { useEffect, useRef, useState } from 'react'; import { GithubUser } from '../types'; import { searchGithubUsers } from '../api'; import { useDebouncedValue } from './use-debounced-value'; interface Props { query: string; debounceMs?: number; } interface State { data: GithubUser[]; total: number; isLoading: boolean; error: string | null; } const DEFAULT_VALUE = { data: [], total: 0, isLoading: false, error: null, }; export function useGithubUserSearch({ query, debounceMs = 350 }: Props) { // Можно сделать, используя useState на каждое состояние отдельно, либо // сделать один общий state. На ваше усмотрение const [state, setState] = useState<State>(DEFAULT_VALUE); const debouncedQuery = useDebouncedValue(query, debounceMs); useEffect(() => { // Пустой запрос — очищаем результат if (!debouncedQuery.trim()) { setState(DEFAULT_VALUE); return; } setState((prev) => ({ ...prev, isLoading: true, error: null })); searchGithubUsers(debouncedQuery) .then((resp) => { setState({ data: resp.items, total: resp.total_count, isLoading: false, error: null, }); }) .catch((err) => { setState((prev) => ({ ...prev, isLoading: false, error: err.message || 'Unknown error', })); }); }, [debouncedQuery]); return state; }
 
Сверстаем простые компоненты
Напомню, что SearchInput мы делаем “тупым” компонентом, чтобы нам было проще переиспользовать данный компонент и обрабатывать состояния во вне.
import { ChangeEvent, FormEvent } from 'react'; interface Props { value: string; onChange: (value: string) => void; } export function SearchInput({ value, onChange }: Props) { const handleChange = (e: ChangeEvent<HTMLInputElement>) => { onChange(e.target.value); }; return ( <input value={value} onChange={handleChange} placeholder="Search GitHub users…" /> ); }
import { GithubUser } from '../types'; interface Props { items: GithubUser[]; } export function ResultsList({ items }: Props) { if (!items.length) return null; return ( // Не забывайте про семантику. // Если вам нужно вывести данные списком -- ul/ol ваш выбор <ul> {items.map((u) => ( <li key={u.id}> <img src={u.avatar_url} alt={`${u.login} avatar`} /> <div> <a href={u.html_url} target="_blank" rel="noreferrer" > {u.login} </a> <div>ID: {u.id}</div> </div> </li> ))} </ul> ); }
 
Финальная сборка
import { useState } from 'react'; import { SearchInput } from '../components/search-input'; import { ResultsList } from '../components/result-list'; import { useGithubUserSearch } from '../hooks/use-github-user-search'; export function GithubUsersSearch() { const [query, setQuery] = useState(''); const { data, total, isLoading, error } = useGithubUserSearch({ query, debounceMs: 350, }); return ( <div> <SearchInput value={query} onChange={setQuery} /> {isLoading && <div>Загрузка…</div>} {error && <div>Ошибка: {error}</div>} {!isLoading && !error && query.trim() && data.length === 0 && ( <div>Ничего не найдено по запросу «{query}»</div> )} <ResultsList items={data} /> </div> ); }
 
Полное решение можете потыкать тут.
 
 
⚖️ Компромиссы
 
 
🔎 Встречные вопросы
  • Что сделаешь с rate limit?
    • валидация минимального кол-ва символов (2-3 символа)
    • debounce на отправку запросов (если ранее не реализовали debounce)
    • Кэш, чтобы не долбить API и не ловить лимиты: const cache = useRef(new Map<string, {items,total_count}>());
  • Какие проблемы видите у текущего решения?
    • проблема race conditions, не отменяются предыдущие запросы (лечится Abort Controller)
  • Какие способы оптимизации своего решения можете предложить?
    • Добавить атрибуты loading="lazy”, width и height картинкам-аватаркам, чтобы снизить layout shift
  • Как сделаешь SWR (отдать кеш сразу, потом дообновить)?
  • Как реализуешь пагинацию (page, «Показать ещё»/infinite scroll)?
 
🚩 Красные флаги
  • Имея достаточно времени, кандидат реализует все в одном файле и не обговаривает даже теоретический вынос в разные компоненты
  • Кандидат бросается решать задачу, не обсудив детали и не декомпозировав задачи
 
🛠 Практика
  • Задача докрутить функционал “дозагрузки” данных, load more
 
📚 Источники / ссылки