Задача реализовать компонент 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. Далее есть два пути:
- написать логику вызова API напрямую в компоненте
- сделать абстракцию в виде хука
Агрументы выбрать вариант с абстракцией:
ㅤ | ✅ Плюсы | ❌ Минусы |
Запрос прямо в компоненте | Если время на собесе ограничено и поджимает, этот вариант проще | Засоряет код всего компонента |
Абстракция-хук | Переиспользуемое решение → мастабируемость
Убирает утилитарный код из фичи | Потребуется немного больше времени, чтобы вынести в отдельный хук |
Хук для запросов
Если на собеседовании вам разрешили воспользоваться библиотекой 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