Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Junior: питання на співбесіді з теми «Стан і дані»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Коли два компоненти мають показувати чи змінювати одні й ті самі дані, стан не може жити в кожному з них окремо - копії розійдуться. Його переносять у найближчого спільного предка й передають вниз через props.

Було - кожна панель сама вирішує, чи відкрита:

function Panel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  // ...
}

Вимога «відкрита лише одна панель одночасно» так не реалізується: панелі не знають одна про одну.

Стало - стан у батька, панелі керовані:

function Accordion() {
  const [openId, setOpenId] = useState('about');

  return (
    <>
      <Panel title="Про нас" isOpen={openId === 'about'} onOpen={() => setOpenId('about')}>...</Panel>
      <Panel title="Доставка" isOpen={openId === 'delivery'} onOpen={() => setOpenId('delivery')}>...</Panel>
    </>
  );
}

function Panel({ title, isOpen, onOpen, children }) {
  return (
    <section>
      <button onClick={onOpen}>{title}</button>
      {isOpen && children}
    </section>
  );
}

Panel став керованим (controlled): ним керує батько через props. Компонент зі своїм внутрішнім станом - некерований (uncontrolled).

Принцип «одного джерела правди»: для кожного шматка стану є один компонент-власник. Інші отримують значення через props і повідомляють про зміни через колбеки.

Куди піднімати:

  • до найближчого спільного предка всіх, хто використовує дані, - не вище. Стан занадто високо змушує перерендерюватися велику частину дерева й ускладнює компоненти;
  • якщо спільний предок дуже далеко і дані треба передавати через багато рівнів («prop drilling»), - спершу композиція (передати готовий JSX через children), потім Context.

Зворотний процес теж корисний: якщо стан використовується лише в одному компоненті, його варто опустити туди (state colocation) - менше зайвих рендерів і простіші батьки.

Типова помилка - дублювати стан: копіювати props у локальний стан дитини (useState(props.value)). Копія не оновиться, коли батько змінить значення. Або компонент керований (значення з props), або некерований (власний стан з початковим значенням), - змішування дає розсинхронізацію.

Докладніше в документації: Спільний стан між компонентами

Надлишковий стан - значення, яке можна обчислити з props чи іншого стану. Його зберігання - джерело помилок, бо дві копії правди рано чи пізно розходяться.

Погано:

const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');   // надлишкове

function handleFirstNameChange(e) {
  setFirstName(e.target.value);
  setFullName(e.target.value + ' ' + lastName);   // треба не забути оновити тут
}

Добре - обчислювати під час рендеру:

const fullName = `${firstName} ${lastName}`;

Типові випадки надлишкового стану:

  • відфільтрований чи відсортований список поруч з оригіналом:
const visibleTodos = todos.filter((t) => (showDone ? true : !t.done));
  • кількості й суми: items.length, items.reduce(...);
  • обраний елемент як копія об'єкта - краще зберігати selectedId і знаходити об'єкт: items.find((i) => i.id === selectedId). Інакше зміна елемента в списку не потрапить у «обраний»;
  • прапорці, що випливають з даних: isEmpty, hasErrors, canSubmit.

Антипатерн - синхронізація ефектом:

const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(todos.filter(...));
}, [todos]);

Зайвий рендер зі старими даними, потім ще один - з новими. Плюс місце для помилки, якщо забути залежність.

А якщо обчислення дороге? Спершу виміряти. Якщо справді повільно - useMemo (або React Compiler зробить це автоматично):

const visibleTodos = useMemo(() => filterTodos(todos, query), [todos, query]);

Коли копія з props доречна: коли це початкове значення, яке далі живе своїм життям, - і тоді props варто назвати відповідно (initialColor, defaultValue), щоб було зрозуміло, що подальші зміни props не враховуються.

Перевірка структури стану: для кожного значення в useState запитати: «чи можна його обчислити з інших?». Якщо так - це не стан.

Докладніше в документації: Вибір структури стану

Найпростіше завантаження в ефекті:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let ignore = false;

    fetch(`/api/users/${userId}`)
      .then((response) => response.json())
      .then((data) => {
        if (!ignore) setUser(data);
      });

    return () => {
      ignore = true;
    };
  }, [userId]);

  if (!user) return <Spinner />;
  return <h1>{user.name}</h1>;
}

Навіщо змінна ignore. Користувач швидко перемикає профілі: userId 1, потім 2. Летять два запити. Якщо відповідь для 1 прийде пізніше за відповідь для 2, без перевірки вона перезапише стан - на екрані профіль 2-го користувача з даними 1-го. Функція очищення попереднього ефекту ставить ignore = true, і застаріла відповідь ігнорується.

Ще краще - скасувати запит:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((error) => {
      if (error.name !== 'AbortError') setError(error);
    });
  return () => controller.abort();
}, [userId]);

StrictMode у розробці запускає ефект двічі - з очищенням між запусками. Перший запит скасовується. Це нормально й саме перевіряє, що очищення написане.

Чого бракує цьому підходу (і чому документація React радить не писати таке вручну у великих застосунках):

  • немає кешу: повернення на сторінку - новий запит і знову спінер;
  • «водоспад» запитів: дочірні компоненти починають завантаження лише після рендеру батька;
  • стани помилки й завантаження - у кожному компоненті вручну;
  • немає попереднього завантаження і повторного запиту при поверненні на вкладку;
  • серверний рендер: ефекти на сервері не виконуються.

Альтернативи:

  • фреймворк (Next.js, React Router з завантажувачами) - дані завантажуються до рендеру маршруту;
  • TanStack Query чи SWR - кеш, повтори, скасування, синхронізація;
  • use() з Suspense і промісом зі стабільного джерела.

useEffect для даних лишається прийнятним для невеликих застосунків і простих випадків - але з очищенням.

Докладніше в документації: Синхронізація з ефектами: завантаження даних

Для стану, яким користуються компоненти на різних рівнях дерева (кошик, список завдань, налаштування), React пропонує поєднання useReducer + Context без сторонніх бібліотек.

const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, []);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        {children}
      </TasksDispatchContext>
    </TasksContext>
  );
}

export function useTasks() {
  const tasks = useContext(TasksContext);
  if (tasks === null) throw new Error('useTasks має бути всередині TasksProvider');
  return tasks;
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

У React 19 сам контекст можна рендерити як провайдер (<TasksContext value={...}>); старий запис <TasksContext.Provider> теж працює.

Використання в будь-якому компоненті під провайдером:

function AddTask() {
  const dispatch = useTasksDispatch();
  return <button onClick={() => dispatch({ type: 'added', text: 'Нове завдання' })}>Додати</button>;
}

function TaskList() {
  const tasks = useTasks();
  return tasks.map((task) => <Task key={task.id} task={task} />);
}

Чому два контексти, а не один:

  • dispatch стабільний - не змінюється між рендерами;
  • компоненти, яким потрібна лише відправка дій (кнопки, форми), підписуються лише на TasksDispatchContext і не перерендерюються, коли змінюється список завдань.

Переваги підходу:

  • логіка змін - в одному редукторі, її легко тестувати;
  • компоненти не передають колбеки через десятки рівнів;
  • власні хуки (useTasks) приховують деталі й перевіряють наявність провайдера.

Обмеження, через які переходять на сховища:

  • будь-яка зміна значення контексту перерендерює всіх його споживачів - навіть тих, кому потрібна лише частина даних. Немає селекторів «підпишись лише на це поле»;
  • провайдери, що вкладаються один в один для кожної частини стану, ускладнюють структуру;
  • немає інструментів налагодження «з коробки», проміжних обробників (middleware), збереження стану.

Для частих оновлень і великого спільного стану - Zustand, Redux Toolkit чи подібні з підписками на частину стану.

Докладніше в документації: Масштабування з reducer і context