Коротка відповідь. State у React – це знімок на момент рендера, а не жива змінна. Тому setCount(count + 1) тричі поспіль збільшить лічильник на 1, а не на 3: усі три виклики читають той самий знімок. Функціональна форма setCount(prev => prev + 1) отримує актуальне значення з черги оновлень і потрібна щоразу, коли нове значення залежить від попереднього. Коли не залежить – пряма форма простіша і правильна. Розберімо механіку, класичний баг із setInterval і межі застосування.
State – це знімок
Кожен рендер «бачить» власні значення пропсів і стану: вони зафіксовані в замиканні цього виклику компонента. Виклик setState не змінює змінну в поточному рендері – він просить React запланувати наступний рендер із новим значенням. Механіка докладно описана в react.dev: State as a Snapshot і Queueing a Series of State Updates.
Класична демонстрація:
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
function handleTripleClick() {
setCount(count + 1); // читає знімок: 0 + 1
setCount(count + 1); // читає той самий знімок: 0 + 1
setCount(count + 1); // і ще раз: 0 + 1
}
return <button onClick={handleTripleClick}>{count}</button>;
}Один клік – і на екрані 1, а не 3. У межах одного обробника count дорівнює 0 в усіх трьох рядках: це та сама змінна того самого рендера. React збатчить три однакові оновлення «постав 1».
Функціональна форма працює інакше – React проганяє чергу оновлень, передаючи кожній функції результат попередньої:
function handleTripleClick() {
setCount((prev) => prev + 1); // 0 → 1
setCount((prev) => prev + 1); // 1 → 2
setCount((prev) => prev + 1); // 2 → 3
}Stale closure: баг із setInterval
Той самий механізм у продакшн-формі. Таймер, який «залипає» на одиниці:
import { useEffect, useState } from 'react';
export function BrokenTimer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1); // ❌ seconds завжди 0
}, 1000);
return () => clearInterval(id);
}, []); // ефект запустився один раз – і замкнув seconds = 0
return <p>{seconds}</p>;
}Колбек інтервалу створений під час першого рендера й назавжди замкнув seconds = 0. Щосекунди виконується setSeconds(0 + 1) – лічильник показує 1 і не рухається.
Правильний фікс – функціональне оновлення, яке не залежить від замкненого значення:
useEffect(() => {
const id = setInterval(() => {
setSeconds((prev) => prev + 1); // ✅ завжди актуальне значення
}, 1000);
return () => clearInterval(id);
}, []);Альтернатива «додай seconds у залежності» теж робоча, але гірша: інтервал перестворюватиметься щосекунди. Функціональна форма дозволяє чесно лишити порожній масив залежностей, бо колбек більше не читає стан із замикання.
Приклад зі списком: дедуплікація WebSocket-повідомлень
Реальний кейс, де без callback-форми не обійтися: повідомлення прилітають пачками з сокета, обробник створений один раз при підписці:
import { useEffect, useState } from 'react';
type ChatMessage = { id: string; text: string };
export function useChat(socket: WebSocket) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => {
function handleMessage(event: MessageEvent) {
const incoming: ChatMessage[] = JSON.parse(event.data);
setMessages((prev) => {
const known = new Set(prev.map((message) => message.id));
const fresh = incoming.filter((message) => !known.has(message.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
}
socket.addEventListener('message', handleMessage);
return () => socket.removeEventListener('message', handleMessage);
}, [socket]);
return messages;
}Три речі, які тут варто вміти пояснити на співбесіді: prev – гарантовано актуальний список, хоч обробник і замкнув давній рендер; Set дає перевірку дублікатів за O(1) замість Array.includes за O(n); повернення prev без змін, коли нових повідомлень немає, – React побачить те саме посилання і пропустить зайвий ре-рендер. І чесний edge case: код відсікає дублікати відносно попереднього стану, але якщо сам пакет incoming містить повтори всередині себе – вони пройдуть; для захисту й від цього дедуплікуй і сам пакет.
Коли функціональна форма НЕ потрібна
Карго-культ «завжди пиши prev =>» – теж червоний прапорець на співбесіді. Пряма форма простіша й коректна, коли нове значення не залежить від старого:
// значення приходить ззовні – з інпута
<input value={name} onChange={(event) => setName(event.target.value)} />
// значення відоме наперед – закриття модалки
<button onClick={() => setIsOpen(false)}>Закрити</button>Писати setName(() => event.target.value) можна, але це шум: залежності від попереднього стану немає, форма-функція нічого не додає, крім зайвих дужок. Правило одним рядком: залежить від попереднього → callback; не залежить → пряме значення.
5 пасток інтерв'ю на цю тему
- «Що виведе console.log одразу після setState?» – старе значення: знімок поточного рендера не змінюється, оновлення побачить наступний рендер.
- «setState синхронний чи асинхронний?» – точніше казати: оновлення батчаться і застосовуються перед наступним рендером; «асинхронний» без пояснення – слабка відповідь.
- «Чому мутація об'єкта в state не оновлює екран?» – React порівнює посилання; мутований об'єкт – те саме посилання, оновлення «не видно». Потрібен новий об'єкт/масив.
- «Таймер залипає на 1 – де баг?» – stale closure із прикладу вище; очікують і діагноз, і обидва варіанти фіксу з поясненням, чому callback кращий.
- «Коли повернення prev без змін корисне?» – коли оновлення може виявитись порожнім: те саме посилання дозволяє React пропустити ре-рендер.
Ці питання майже завжди йдуть у зв'язці з механікою списків – reconciliation та keys – і разом закривають блок «рендеринг і стан» типової співбесіди; ширший план підготовки є в гайді по React-співбесіді.
Практичне завдання
Напиши компонент «автозбереження чернетки»: textarea + функція save(text), яка викликається не частіше ніж раз на 2 секунди після останнього введення. Зроби спершу навмисно зламану версію з setTimeout, що замикає старий текст, побач баг, потім полагодь – через ref або через ефект із коректними залежностями. Пояснення різниці між цими двома фіксами – готова відповідь на добру третину питань про стан і замикання.