# Функціональне оновлення state у React: коли потрібен setState(prev => ...)

> State у React – це знімок. Чому setCount(count + 1) тричі поспіль дає +1, як лікується stale closure в setInterval, коли callback-форма потрібна, а коли зайва. Робочі приклади TypeScript і 5 пасток зі співбесід.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-09-24
- Категорія: React і Frontend
- Canonical: https://cookiesoftware.io/blog/react-functional-state-update

---

**Коротка відповідь.** State у React – це знімок на момент рендера, а не жива змінна. Тому `setCount(count + 1)` тричі поспіль збільшить лічильник на 1, а не на 3: усі три виклики читають той самий знімок. Функціональна форма `setCount(prev => prev + 1)` отримує актуальне значення з черги оновлень і потрібна щоразу, коли **нове значення залежить від попереднього**. Коли не залежить – пряма форма простіша і правильна. Розберімо механіку, класичний баг із `setInterval` і межі застосування.

## State – це знімок

Кожен рендер «бачить» власні значення пропсів і стану: вони зафіксовані в замиканні цього виклику компонента. Виклик `setState` не змінює змінну в поточному рендері – він просить React запланувати наступний рендер із новим значенням. Механіка докладно описана в react.dev: [State as a Snapshot](https://react.dev/learn/state-as-a-snapshot) і [Queueing a Series of State Updates](https://react.dev/learn/queueing-a-series-of-state-updates).

Класична демонстрація:

```tsx
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 проганяє чергу оновлень, передаючи кожній функції результат попередньої:

```tsx
function handleTripleClick() {
  setCount((prev) => prev + 1); // 0 → 1
  setCount((prev) => prev + 1); // 1 → 2
  setCount((prev) => prev + 1); // 2 → 3
}
```

## Stale closure: баг із setInterval

Той самий механізм у продакшн-формі. Таймер, який «залипає» на одиниці:

```tsx
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` і не рухається.

Правильний фікс – функціональне оновлення, яке не залежить від замкненого значення:

```tsx
useEffect(() => {
  const id = setInterval(() => {
    setSeconds((prev) => prev + 1); // ✅ завжди актуальне значення
  }, 1000);
  return () => clearInterval(id);
}, []);
```

Альтернатива «додай `seconds` у залежності» теж робоча, але гірша: інтервал перестворюватиметься щосекунди. Функціональна форма дозволяє чесно лишити порожній масив залежностей, бо колбек більше не читає стан із замикання.

## Приклад зі списком: дедуплікація WebSocket-повідомлень

Реальний кейс, де без callback-форми не обійтися: повідомлення прилітають пачками з сокета, обробник створений один раз при підписці:

```tsx
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 =>» – теж червоний прапорець на співбесіді. Пряма форма простіша й коректна, коли нове значення **не залежить** від старого:

```tsx
// значення приходить ззовні – з інпута
<input value={name} onChange={(event) => setName(event.target.value)} />

// значення відоме наперед – закриття модалки
<button onClick={() => setIsOpen(false)}>Закрити</button>
```

Писати `setName(() => event.target.value)` можна, але це шум: залежності від попереднього стану немає, форма-функція нічого не додає, крім зайвих дужок. Правило одним рядком: **залежить від попереднього → callback; не залежить → пряме значення.**

## 5 пасток інтерв'ю на цю тему

1. **«Що виведе console.log одразу після setState?»** – старе значення: знімок поточного рендера не змінюється, оновлення побачить наступний рендер.
2. **«setState синхронний чи асинхронний?»** – точніше казати: оновлення батчаться і застосовуються перед наступним рендером; «асинхронний» без пояснення – слабка відповідь.
3. **«Чому мутація об'єкта в state не оновлює екран?»** – React порівнює посилання; мутований об'єкт – те саме посилання, оновлення «не видно». Потрібен новий об'єкт/масив.
4. **«Таймер залипає на 1 – де баг?»** – stale closure із прикладу вище; очікують і діагноз, і обидва варіанти фіксу з поясненням, чому callback кращий.
5. **«Коли повернення prev без змін корисне?»** – коли оновлення може виявитись порожнім: те саме посилання дозволяє React пропустити ре-рендер.

Ці питання майже завжди йдуть у зв'язці з механікою списків – [reconciliation та keys](/blog/react-reconciliation-keys) – і разом закривають блок «рендеринг і стан» типової співбесіди; ширший план підготовки є в [гайді по React-співбесіді](/blog/react-interview-guide).

## Практичне завдання

Напиши компонент «автозбереження чернетки»: textarea + функція `save(text)`, яка викликається не частіше ніж раз на 2 секунди після останнього введення. Зроби спершу навмисно зламану версію з `setTimeout`, що замикає старий текст, побач баг, потім полагодь – через ref або через ефект із коректними залежностями. Пояснення різниці між цими двома фіксами – готова відповідь на добру третину питань про стан і замикання.
