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

React: питання на співбесіді рівня Middle

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

34 питань

useEffect - для синхронізації компонента з зовнішньою системою: підписка на WebSocket, таймер, сторонній віджет, ручна робота з DOM, запит даних.

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();   // очищення
}, [roomId]);                              // залежності

Залежності: ефект перезапускається, коли змінилося будь-яке значення з масиву. В масиві мають бути всі реактивні значення, які ефект використовує, - лінтер react-hooks/exhaustive-deps за цим стежить. Порожній масив - лише після першого монтування.

Очищення виконується перед наступним запуском ефекту і при демонтуванні. Без нього - витоки: подвійні підписки, таймери, що живуть після компонента. У режимі розробки React навмисно монтує компонент двічі (StrictMode), щоб відсутність очищення стала помітною.

Коли ефект НЕ потрібен - найчастіша помилка:

  • Похідні дані. Не useEffect(() => setFullName(first + ' ' + last), [first, last]), а просто const fullName = first + ' ' + last під час рендеру. Дороге обчислення - useMemo.
  • Реакція на дію користувача. Відправка форми, аналітика кліку - в обробнику події, а не в ефекті, що стежить за станом.
  • Скидання стану при зміні props - через key на компоненті.

Запити даних в ефекті потребують обробки гонок (прапорець ignore в очищенні чи AbortController). У реальних застосунках для цього використовують TanStack Query, SWR або засоби фреймворку.

Докладніше в документації: Можливо, вам не потрібен ефект

useRef(initial) повертає об'єкт { current }, який живе весь час існування компонента. На відміну від стану, зміна ref.current не викликає рендер.

Два застосування:

  1. Доступ до DOM-елемента:
function Search() {
  const inputRef = useRef(null);

  return (
    <>
      <input ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>Шукати</button>
    </>
  );
}
  1. Значення, яке треба зберігати між рендерами, але не показувати: ID таймера, попереднє значення, прапорець «чи вже відправлено», екземпляр сторонньої бібліотеки.
const timerRef = useRef(null);

function start() {
  timerRef.current = setInterval(tick, 1000);
}

function stop() {
  clearInterval(timerRef.current);
}

Стан чи ref:

  • Значення впливає на те, що показано, - стан.
  • Значення потрібне лише логіці й не має перемальовувати компонент - ref.

Правила:

  • Не читати й не записувати ref.current під час рендеру (крім лінивої ініціалізації) - рендер має бути чистим, а зміна ref не відображається в UI.
  • Для передачі ref у власний компонент у React 19 ref - звичайний prop. Раніше для цього потрібен був forwardRef.

Докладніше в документації: useRef

useReducer виносить логіку оновлення стану в окрему функцію-редуктор: компонент лише описує, що сталося (дію), а редуктор вирішує, як змінюється стан.

function cartReducer(state, action) {
  switch (action.type) {
    case 'added':
      return { ...state, items: [...state.items, action.item] };
    case 'removed':
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case 'cleared':
      return { ...state, items: [] };
    default:
      throw new Error(`Невідома дія: ${action.type}`);
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  return <button onClick={() => dispatch({ type: 'cleared' })}>Очистити</button>;
}

Коли useReducer кращий:

  • кілька пов'язаних значень змінюються разом (статус запиту, дані, помилка; кроки майстра);
  • багато різних оновлень одного стану розкидано по обробниках - редуктор збирає їх в одному місці;
  • наступний стан залежить від попереднього складним чином;
  • тестування: редуктор - чиста функція, її тестують без React: expect(cartReducer(state, action)).toEqual(...);
  • передача вниз: dispatch стабільний між рендерами - його безпечно передавати в дочірні компоненти й контекст без useCallback.

Коли досить useState: незалежні прості значення (відкрито/закрито, текст у полі).

Правила редуктора:

  • чистий: без запитів, таймерів, випадкових значень - React може викликати його двічі в StrictMode;
  • не мутує стан - повертає новий об'єкт;
  • дії описують подію ('added', 'login_failed'), а не «встановити поле» - тоді логіка зосереджена в редукторі, а не в компонентах.

Лінива ініціалізація - третій аргумент: useReducer(reducer, userId, createInitialState) - функція викличеться лише один раз.

Масштабування: useReducer + Context - простий спосіб керувати станом частини застосунку без зовнішніх бібліотек. Для складнішого - Redux Toolkit, ідеї якого побудовані на тих самих редукторах.

Докладніше в документації: useReducer

Обидва хуки мають однаковий API, різниця - коли вони виконуються відносно відмальовування екрана.

  • useEffect - після того, як браузер відмалював зміни. Не блокує показ - підходить для більшості ефектів (запити, підписки, логування);
  • useLayoutEffect - після оновлення DOM, але до відмальовування. Браузер чекає, поки ефект виконається.

Коли потрібен useLayoutEffect: коли треба виміряти DOM і одразу на основі вимірювання змінити розмітку - щоб користувач не побачив проміжного стану.

Класичний приклад - підказка (tooltip), яка має з'явитися над елементом, а якщо зверху немає місця - під ним:

function Tooltip({ targetRect, children }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    setTooltipHeight(ref.current.getBoundingClientRect().height);
  }, []);

  const top = targetRect.top - tooltipHeight < 0
    ? targetRect.bottom
    : targetRect.top - tooltipHeight;

  return <div ref={ref} style={{ position: 'fixed', top }}>{children}</div>;
}

З useEffect користувач на мить побачив би підказку не на тому місці, а потім вона «стрибнула» б. З useLayoutEffect вимірювання й перерахунок відбуваються до першого показу.

Інші випадки: відновлення позиції прокрутки, анімації, що стартують від виміряної позиції, синхронізація зі сторонніми бібліотеками, що вимірюють DOM.

Ціна: useLayoutEffect блокує відмальовування. Важка робота в ньому робить інтерфейс повільнішим. За замовчуванням - useEffect, а useLayoutEffect - лише коли видно «мерехтіння».

Серверний рендер: на сервері немає DOM і розкладки - ні useEffect, ні useLayoutEffect там не виконуються. Компонент, що залежить від вимірювань, на сервері рендериться без них; варіанти - показувати його лише на клієнті або мати розумний вигляд за замовчуванням.

useInsertionEffect - ще раніший хук, лише для бібліотек CSS-in-JS, що вставляють стилі до будь-яких вимірювань.

Докладніше в документації: useLayoutEffect

Проблема. Ефект має перезапускатися, коли змінюються одні значення, але використовує й інші, через які перезапускатися не повинен.

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      showNotification('Підключено', theme);   // потрібна поточна тема
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);   // зміна теми перепідключає до чату!
}

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

useEffectEvent (стабільний з React 19.2) виносить нереактивну частину логіки в «подію ефекту»: функцію, яка завжди бачить актуальні props і стан, але не є залежністю:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Підключено', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);   // лише справжня причина перепідключення
}

Як розрізняти:

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

Інші типові випадки: аналітика з поточними даними користувача при зміні сторінки, обробник інтервалу, що читає свіжий стан, колбек з props (onChange), який батько щоразу створює заново.

Обмеження:

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

До появи хука це розв'язували через useRef з останнім значенням колбеку - useEffectEvent робить те саме офіційно й безпечніше для конкурентного рендерингу.

Докладніше в документації: useEffectEvent

Компонент рендериться повторно, коли:

  1. змінився його стан (useState, useReducer);
  2. змінився контекст, який він читає;
  3. перерендерився батько - за замовчуванням разом з ним рендеряться всі дочірні компоненти, незалежно від того, чи змінилися їхні props.

Третій пункт часто дивує. Але рендер - це лише виклик функції й порівняння віртуального DOM; реальний DOM змінюється тільки там, де щось справді змінилося. Зазвичай це дешево.

React.memo пропускає рендер компонента, якщо його props поверхнево не змінилися (Object.is для кожного prop):

const Chart = memo(function Chart({ data, onSelect }) {
  /* важкий рендер */
});

Чому memo часто «не працює»: батько передає новий об'єкт чи функцію на кожен рендер:

<Chart data={rows.filter(isVisible)} onSelect={(id) => select(id)} />
// і data, і onSelect - нові посилання щоразу, memo марний

Тут потрібні useMemo для data і useCallback для onSelect - або передавати примітиви.

Коли memo справді доречний: компонент рендериться помітно довго і часто перерендерюється з тими самими props - великі списки, графіки, редактори. Для дрібних компонентів порівняння props може коштувати стільки ж, скільки рендер.

Альтернатива без мемоізації - правильна структура: опустити стан нижче, до компонента, якому він потрібен, або передати важку частину як children - тоді вона не перерендериться разом із батьком.

React Compiler (стабільний з 2025 року) автоматично мемоізує компоненти й значення, і там, де його ввімкнено, ручні memo/useMemo/useCallback здебільшого не потрібні.

Докладніше в документації: memo

  • useMemo(fn, deps) - кешує результат обчислення між рендерами, доки залежності не змінились.
  • useCallback(fn, deps) - кешує саму функцію (те саме посилання). По суті, useMemo(() => fn, deps).
const visible = useMemo(
  () => todos.filter((t) => matches(t, filter)),
  [todos, filter],
);

const handleSelect = useCallback((id) => setSelected(id), []);

Коли вони потрібні:

  1. Справді дороге обчислення - фільтрація чи сортування тисяч елементів, побудова дерева. Перевірити легко: console.time навколо обчислення; якщо це мілісекунди й більше - є сенс.
  2. Стабільне посилання для дочірнього компонента з memo - інакше нові об'єкт чи функція на кожен рендер скасовують мемоізацію.
  3. Значення в залежностях ефекту - функція чи об'єкт, створені заново на кожен рендер, перезапускали б useEffect щоразу.

Коли зайві:

  • Прості обчислення (a + b, items.length, форматування рядка) - мемоізація коштує більше за них.
  • Колбеки для звичайних DOM-елементів (<button onClick={...}>) - кнопці байдуже, нова це функція чи ні.
  • «Про всяк випадок» скрізь - код важче читати, а неправильні залежності створюють баги зі застарілими значеннями.

Важливо: useMemo - оптимізація, а не гарантія. React може скинути кеш, тож код має працювати правильно й без нього.

React Compiler робить цю мемоізацію автоматично на етапі збирання. У проєктах, де він увімкнений, ручні useMemo і useCallback здебільшого прибирають.

Докладніше в документації: useMemo

Стан зберігається не «в компоненті», а в позиції компонента в дереві. React зберігає стан, доки на тому самому місці дерева рендериться той самий тип компонента.

{isFancy ? <Counter fancy /> : <Counter />}

Перемикання isFancy не скидає лічильник: на тому самому місці той самий тип Counter, змінилися лише props.

Стан скидається, коли:

  • на тому самому місці з'являється інший тип компонента (<Counter /> → <Timer />) або інший тег (<div> → <section> - скидається все піддерево);
  • компонент прибрано з дерева і повернуто;
  • змінився key.

key для скидання стану - найважливіший практичний прийом:

<ContactForm key={contact.id} contact={contact} />

Перемкнули контакт - змінився key - React створює новий екземпляр форми з чистим станом. Без key форма зберегла б введений текст від попереднього контакту.

Це заміна поширеному антипатерну «скинути стан в ефекті при зміні props»:

// погано: зайвий рендер зі старими даними, потім скидання
useEffect(() => { setDraft(''); }, [contact.id]);

Пастка - компонент, оголошений усередині компонента:

function Page() {
  const [text, setText] = useState('');

  function Field() {   // нова функція на кожному рендері Page
    return <input value={text} onChange={(e) => setText(e.target.value)} />;
  }

  return <Field />;
}

На кожному рендері Field - новий тип компонента, тож React розмонтовує старий і монтує новий: поле губить фокус після кожної літери. Компоненти оголошують на верхньому рівні модуля.

Зворотний бік - стан «прилипає», коли це не очікується: однакова позиція в різних гілках умови, однаковий key у різних списках. Тоді допомагає різний key для гілок.

Зберегти стан прихованого компонента (вкладки, бічна панель) - не прибирати його з дерева: CSS-приховування, <Activity mode="hidden"> або винести стан угору до батька.

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

Портал рендерить дочірні елементи в інший вузол DOM, ніж той, де знаходиться компонент:

import { createPortal } from 'react-dom';

function Modal({ open, onClose, children }) {
  if (!open) return null;

  return createPortal(
    <div className="fixed inset-0 grid place-items-center bg-black/50" onClick={onClose}>
      <div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body,
  );
}

Навіщо: модальні вікна, підказки, випадні меню мають бути поверх усього. Якщо їхня розмітка глибоко в дереві, батьківські стилі заважають: overflow: hidden обрізає, transform ламає position: fixed, z-index обмежений контекстом накладання батька.

Ключова особливість - події. Портал змінює місце в DOM, але не в дереві React. Події спливають за деревом React:

<div onClick={() => console.log('клік у батьку')}>
  <Modal open>
    <button>Кнопка у вікні</button>   {/* клік спливе до div батька! */}
  </Modal>
</div>

Клік по кнопці, яка в DOM лежить у body, все одно викличе onClick батька. Те саме з контекстом: портал бачить провайдери свого предка в React, а не за місцем у DOM.

Це зручно (стан, контекст і обробники працюють як для звичайних дітей), але інколи несподівано - наприклад, «клік поза меню» через contains() у DOM вважатиме клік у порталі зовнішнім.

Що ще потрібно для якісного модального вікна (портал цього не дає):

  • фокус усередині вікна і повернення фокусу після закриття;
  • закриття по Escape;
  • блокування прокрутки сторінки;
  • aria-modal, aria-labelledby.

Нативна альтернатива - <dialog> з showModal(): браузер сам виносить вікно на верхній шар (без порталу й боротьби із z-index), дає фокус і Escape. Для багатьох випадків простіше за портал.

SSR: на сервері порталів у document.body немає - модальні вікна зазвичай рендерять лише на клієнті (після монтування).

Докладніше в документації: createPortal

Без обробки помилка під час рендеру будь-якого компонента розмонтовує весь застосунок - користувач бачить порожню сторінку. Межа помилок перехоплює помилки свого піддерева й показує запасний інтерфейс.

Межі помилок досі пишуться лише класами - хука-аналога немає:

class ErrorBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };   // наступний рендер покаже fallback
  }

  componentDidCatch(error, info) {
    reportError(error, info.componentStack);   // у моніторинг
  }

  render() {
    if (this.state.error) return this.props.fallback;
    return this.props.children;
  }
}
<ErrorBoundary fallback={<p>Віджет не завантажився</p>}>
  <RevenueChart />
</ErrorBoundary>

На практиці частіше беруть готовий пакет react-error-boundary - з функцією скидання (resetErrorBoundary), ключами для автоматичного скидання і хуком useErrorBoundary.

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

Чого НЕ ловить:

  • обробники подій (onClick) - там звичайний try/catch;
  • асинхронний код - setTimeout, проміси без use(), колбеки запитів;
  • помилки в самій межі - вони йдуть до наступної межі вище;
  • серверний рендер - на сервері помилки обробляються інакше.

Щоб передати помилку з обробника чи асинхронного коду в межу, її «кидають» під час рендеру: зберегти в стан і throw у рендері, або showBoundary(error) з react-error-boundary.

Де ставити межі:

  • верхня - на весь застосунок, щоб замість білого екрана був зрозумілий текст і кнопка оновлення;
  • навколо незалежних віджетів - графік, коментарі, рекомендації: зламаний віджет не зносить сторінку;
  • на рівні маршруту - роутери (React Router, Next.js error.tsx) мають власні межі.

React 19: опції кореня onCaughtError і onUncaughtError у createRoot - централізоване логування помилок, перехоплених межами і неперехоплених.

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

Клієнтський стан належить інтерфейсу: відкрите модальне вікно, текст у полі, обрана вкладка. Він живе в браузері, і застосунок - його єдиний власник.

Серверний стан - копія даних, що живуть на сервері: список замовлень, профіль, вакансії. Його особливості:

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

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

TanStack Query бере це на себе:

import { useQuery } from '@tanstack/react-query';

function Orders({ status }) {
  const { data, isPending, error } = useQuery({
    queryKey: ['orders', { status }],
    queryFn: () => fetch(`/api/orders?status=${status}`).then((r) => r.json()),
    staleTime: 30_000,
  });

  if (isPending) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <OrderList orders={data} />;
}

Що дає:

  • кеш за ключем (queryKey): повернення на сторінку показує дані миттєво, а оновлення йде у фоні;
  • дедуплікація: десять компонентів з тим самим ключем - один запит;
  • stale-while-revalidate: застарілі дані показуються, поки завантажуються свіжі;
  • повторні запити при поверненні на вкладку, відновленні мережі, з інтервалом;
  • повтори при помилках з затримкою;
  • мутації (useMutation) з інвалідацією кешу й оптимістичними оновленнями;
  • скасування застарілих запитів і відсутність гонитви.

Ключові налаштування:

  • staleTime - скільки даних вважаються свіжими (за замовчуванням 0 - застарілі одразу, тож повторний запит при кожному монтуванні). Для більшості даних варто задати секунди чи хвилини;
  • gcTime - скільки невикористовувані дані лишаються в кеші;
  • ключ має містити всі параметри запиту - інакше різні запити ділитимуть один кеш.

Розподіл відповідальності: серверний стан - TanStack Query (чи SWR, RTK Query, завантажувачі фреймворку), клієнтський - useState, Context чи невеликий стор. Багато застосунків після переходу виявляють, що глобальний стор їм майже не потрібен.

Докладніше в документації: TanStack Query: огляд

Context - механізм передачі значення вниз по дереву, а не повноцінне керування станом. Його головне обмеження: зміна значення перерендерює всіх споживачів, навіть якщо їм потрібна лише частина даних.

Зовнішній стор тримає стан поза деревом React, а компоненти підписуються на окремі частини через селектори:

// Zustand
const useCartStore = create((set) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function CartBadge() {
  const count = useCartStore((state) => state.items.length);   // лише кількість
  return <span>{count}</span>;
}

CartBadge перерендериться лише коли зміниться кількість, а не будь-що в сторі.

// Redux Toolkit
const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    added(state, action) {
      state.items.push(action.payload);   // Immer усередині - виглядає як мутація, але незмінно
    },
  },
});

Коли зовнішній стор виправданий:

  • часті оновлення спільного стану (редактор, канвас, дашборд у реальному часі) - селектори відсікають зайві рендери;
  • багато компонентів у різних частинах дерева читають і змінюють той самий стан;
  • складна логіка змін, яку зручно тримати поза компонентами й тестувати окремо;
  • доступ поза React: з обробників WebSocket, сервісів, роутера;
  • інструменти: Redux DevTools (історія дій, «подорож у часі»), збереження стану, middleware.

Коли не потрібен:

  • дані з сервера - для них краще TanStack Query чи RTK Query, а не ручне зберігання в сторі;
  • стан однієї сторінки чи компонента - useState/useReducer;
  • рідко змінювані значення (тема, мова, поточний користувач) - Context цілком достатній.

Порівняння бібліотек:

  • Redux Toolkit - структурований, з DevTools і RTK Query, більше коду, але передбачуваний для великих команд;
  • Zustand - мінімальний API, хук-стор без провайдера;
  • Jotai - атомарний підхід: стан з дрібних незалежних атомів.

Усі вони побудовані на useSyncExternalStore - безпечні для конкурентного рендерингу.

Пастка селекторів: селектор, що повертає новий об'єкт ((s) => ({ a: s.a, b: s.b })), щоразу дає «інше» значення і перерендерює компонент. Потрібні окремі селектори чи порівняння shallow.

Докладніше в документації: Redux Toolkit: початок роботи

Стан каталогу (фільтри, пошук, сортування, сторінка) у useState зникає при оновленні сторінки, не працює з кнопкою «Назад» і не передається посиланням. URL - природне місце для такого стану.

Що дає стан в URL:

  • посилання відтворює екран: «ось вакансії PHP у Києві, віддалено» - одне посилання;
  • «Назад» і «Вперед» повертають попередні фільтри;
  • оновлення сторінки нічого не губить;
  • серверний рендер і SEO: сервер бачить параметри й рендерить правильний вміст;
  • аналітика бачить, як саме користуються фільтрами.

З React Router:

import { useSearchParams } from 'react-router';

function Vacancies() {
  const [searchParams, setSearchParams] = useSearchParams();
  const city = searchParams.get('city') ?? 'all';
  const page = Number(searchParams.get('page') ?? 1);

  function changeCity(nextCity) {
    setSearchParams((params) => {
      params.set('city', nextCity);
      params.delete('page');   // новий фільтр - перша сторінка
      return params;
    });
  }

  // ...
}

Значення читаються з URL під час рендеру - окремий useState для них не потрібен. URL і є джерелом правди.

Без роутера - URLSearchParams + history.replaceState/pushState і підписка на popstate (через useSyncExternalStore).

Що варто врахувати:

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

Що НЕ варто тримати в URL: тимчасовий стан інтерфейсу (відкрите меню, наведення), персональні дані, великі обсяги даних.

Для Laravel-бекенду формат на кшталт tags[]=php&tags[]=vue зручний: сервер отримає масив.

Докладніше в документації: React Router: useSearchParams

Збереження стану між сесіями (тема, згорнута бічна панель, чернетка форми) - типова задача. Найпростіший варіант:

function useLocalStorageState(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // сховище заповнене чи недоступне - стан працює і без збереження
    }
  }, [key, value]);

  return [value, setValue];
}

Лінива ініціалізація (функція в useState) - читання з localStorage лише один раз, а не на кожному рендері.

Пастки:

1. Серверний рендер. На сервері localStorage не існує - код вище впаде з ReferenceError. А якщо перевіряти typeof window, сервер відрендерить початкове значення, клієнт - збережене, і React повідомить про розбіжність гідратації.

Правильно - на першому рендері використовувати те саме значення, що й сервер, і читати сховище після монтування; або useSyncExternalStore з getServerSnapshot, що повертає значення за замовчуванням. Для теми, щоб не було «спалаху», - невеликий скрипт у <head>, що ставить клас до запуску React, або cookie, яку бачить і сервер.

2. Синхронізація між вкладками. Зміни в одній вкладці не видно в іншій без підписки на подію storage. useSyncExternalStore з підпискою на неї вирішує це.

3. Зміна формату даних. Нова версія застосунку очікує інший формат, а в браузерах лежать старі дані. Додавайте версію в ключ (settings:v2) чи валідацію при читанні (Zod) - некоректні дані замінюються значенням за замовчуванням.

4. Винятки. localStorage кидає помилки в приватному режимі, при заповненому сховищі (~5 МБ), при заборонених cookie - доступ обгортається в try/catch.

5. Безпека. Будь-який скрипт на сторінці читає localStorage - туди не кладуть токени автентифікації й персональні дані.

6. Продуктивність. setItem синхронний; запис великого об'єкта на кожне натискання клавіші - помітні затримки. Для чернеток - debounce.

Готові рішення: useLocalStorage з бібліотек хуків, middleware persist у Zustand, redux-persist - вони вже враховують частину цих проблем.

Докладніше в документації: Window.localStorage

useActionState (React 19) - стан, що оновлюється результатом дії (Action). Зручно для форм: дія відправляє дані, а повернене значення стає новим станом - повідомленням про успіх чи помилками.

import { useActionState } from 'react';

async function subscribe(previousState, formData) {
  const response = await fetch('/api/subscribe', {
    method: 'POST',
    headers: { Accept: 'application/json' },
    body: formData,
  });

  if (response.status === 422) {
    const { errors } = await response.json();
    return { errors, email: formData.get('email') };
  }

  return { success: true };
}

function SubscribeForm() {
  const [state, formAction, isPending] = useActionState(subscribe, { errors: {} });

  if (state.success) return <p>Дякуємо за підписку!</p>;

  return (
    <form action={formAction}>
      <input name="email" type="email" defaultValue={state.email} />
      {state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}
      <button disabled={isPending}>{isPending ? 'Надсилаємо...' : 'Підписатися'}</button>
    </form>
  );
}

Як працює:

  • useActionState(reducerAction, initialState) повертає [state, dispatchAction, isPending];
  • reducerAction(previousState, payload) - як редюсер у useReducer, але може бути асинхронним і мати побічні ефекти. Для форми payload - це FormData;
  • isPending - чи виконується дія;
  • кілька викликів ставляться в чергу й виконуються послідовно: кожен отримує результат попереднього. Подвійне натискання не дасть двох паралельних запитів у довільному порядку;
  • dispatchAction треба викликати з дії: передати в <form action> чи <button formAction>, або обгорнути в startTransition.

Пастки:

  • форма скидається після успішної дії - неконтрольовані поля очищуються. Щоб не губити введене при помилці, повертайте значення в стані (defaultValue={state.email}, як у прикладі);
  • кинутий виняток у дії скасовує всі дії в черзі й показує найближчий Error Boundary. Очікувані помилки (валідація) - повертати як стан, а не кидати;
  • початковий стан і тип результату мають збігатися - інакше TypeScript скаржиться на невідповідність типів.

Server Functions: з Next.js чи іншим RSC-фреймворком дія може бути серверною функцією ('use server') - тоді форма працює навіть до завантаження JavaScript (третій аргумент permalink).

Докладніше в документації: useActionState

Питання рівня Middle з реальних технічних співбесід - 34 питання у 8 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Junior 33 Senior 33

Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії