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 не викликає рендер.
Два застосування:
- Доступ до DOM-елемента:
function Search() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Шукати</button>
</>
);
}
- Значення, яке треба зберігати між рендерами, але не показувати: 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.
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, ідеї якого побудовані на тих самих редукторах.
Обидва хуки мають однаковий 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, що вставляють стилі до будь-яких вимірювань.
Проблема. Ефект має перезапускатися, коли змінюються одні значення, але використовує й інші, через які перезапускатися не повинен.
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 робить те саме офіційно й безпечніше для конкурентного рендерингу.
Компонент рендериться повторно, коли:
- змінився його стан (
useState,useReducer); - змінився контекст, який він читає;
- перерендерився батько - за замовчуванням разом з ним рендеряться всі дочірні компоненти, незалежно від того, чи змінилися їхні 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 здебільшого не потрібні.
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), []);
Коли вони потрібні:
- Справді дороге обчислення - фільтрація чи сортування тисяч елементів, побудова дерева. Перевірити легко:
console.timeнавколо обчислення; якщо це мілісекунди й більше - є сенс. - Стабільне посилання для дочірнього компонента з
memo- інакше нові об'єкт чи функція на кожен рендер скасовують мемоізацію. - Значення в залежностях ефекту - функція чи об'єкт, створені заново на кожен рендер, перезапускали б
useEffectщоразу.
Коли зайві:
- Прості обчислення (
a + b,items.length, форматування рядка) - мемоізація коштує більше за них. - Колбеки для звичайних DOM-елементів (
<button onClick={...}>) - кнопці байдуже, нова це функція чи ні. - «Про всяк випадок» скрізь - код важче читати, а неправильні залежності створюють баги зі застарілими значеннями.
Важливо: useMemo - оптимізація, а не гарантія. React може скинути кеш, тож код має працювати правильно й без нього.
React Compiler робить цю мемоізацію автоматично на етапі збирання. У проєктах, де він увімкнений, ручні useMemo і useCallback здебільшого прибирають.
Стан зберігається не «в компоненті», а в позиції компонента в дереві. 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 немає - модальні вікна зазвичай рендерять лише на клієнті (після монтування).
Без обробки помилка під час рендеру будь-якого компонента розмонтовує весь застосунок - користувач бачить порожню сторінку. Межа помилок перехоплює помилки свого піддерева й показує запасний інтерфейс.
Межі помилок досі пишуться лише класами - хука-аналога немає:
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 чи невеликий стор. Багато застосунків після переходу виявляють, що глобальний стор їм майже не потрібен.
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.
Стан каталогу (фільтри, пошук, сортування, сторінка) у 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 зручний: сервер отримає масив.
Збереження стану між сесіями (тема, згорнута бічна панель, чернетка форми) - типова задача. Найпростіший варіант:
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 - вони вже враховують частину цих проблем.
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).
Питання рівня Middle з реальних технічних співбесід - 34 питання у 8 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії