Вынесение событий из Эффектов

Обработчики событий перезапускаются только при повторении одного и того же взаимодействия. В отличие от обработчиков событий, Эффекты перезапускаются, если одно из считываемых значений, например, проп или состояние, изменилось во время рендера. Но иногда вам нужно объединить оба поведения так, чтобы эффект перезапускался при изменении одних значений и игнорировал изменения других. На этой странице вы узнаете, как это сделать.

Вы узнаете

  • В каких случаях использовать обработчики событий, а в каких — Эффекты
  • Почему Эффекты реактивны, а обработчики событий — нет
  • Что делать, если вы хотите, чтобы части кода вашего Эффекта не были реактивными
  • Что такое События эффектов, и как вынести их из Эффектов
  • Как считывать последние пропсы и состояния из Эффектов, используя События эффектов.

Выбор между обработчиками событий и Эффектами

Для начала, вспомним различия между обработчиками событий и Эффектами.

Представим, что вы разрабатываете компонент чата, который должен удовлетворять следующим требованиям:

  1. Компонент должен автоматически подключаться к выбранному чату.
  2. При клике на кнопку “Отправить” должно отправляться сообщение в чат.

Допустим, вы уже реализовали логику компонента, но не уверены, куда его поместить. Использовать обработчики событий или Эффекты? Перед тем, как ответить на этот вопрос, подумайте, в чём предназначение этого кода.

Обработчики событий выполняются в ответ на определённые взаимодействия

С точки зрения пользователя, отправка сообщения должна произойти потому, что была нажата конкретная кнопка “Отправить”. Пользователь будет растерян, если вы отправите его сообщение в неожиданное для него время или по любой другой причине. Вот почему отправка сообщения должна быть обработчиком событий. Обработчики событий позволяют вам обрабатывать определённые взаимодействия:

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}

Используя обработчик событий, вы можете быть уверены, что sendMessage(message) будет выполнен, только если пользователь нажмёт на кнопку.

Эффекты запускаются всякий раз, когда необходима синхронизация.

Помните, что вам также нужно поддерживать компонент подключенным к чату. Где запускать этот код?

Этот код не предназначен для обработки взаимодействия. Неважно, почему или как пользователь перешёл на экран чата. Теперь, когда он смотрит на него и может взаимодействовать с ним, компонент должен оставаться подключенным к выбранному серверу чата. Даже если пользователь только открыл чат и вообще не выполнял никаких взаимодействий, вам всё равно нужно будет подключиться. Вот почему это Эффект:

function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}

С этим кодом вы можете быть уверены, что чат всегда подключен к выбранному серверу, независимо от действий пользователя. Независимо от того, открыл ли пользователь только ваше приложение, выбрал другой чат или перешёл на другой экран и обратно, ваш эффект гарантирует, что компонент останется синхронизированным с выбранным в данный момент чатом и повторно подключится, когда это необходимо.

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  function handleSendClick() {
    sendMessage(message);
  }

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

Реактивные значения и реактивная логика

Интуитивно мы понимаем, что обработчики событий всегда запускаются “вручную”, например, при нажатии на кнопку. Эффекты же запускаются “автоматически”: они запускаются и перезапускаются так часто, как это необходимо, чтобы оставаться синхронизированным.

Есть более точное понимание этого вопроса.

Пропсы, состояние и переменные, объявленные внутри тела компонента, называются реактивными значениями. В этом примере serverUrl не является реактивным значением, а roomId и message являются. Они участвуют в потоке данных рендера:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

// ...
}

Подобные реактивные значения могут измениться при повторном рендере. Например, пользователь может отредактировать message или выбрать другой roomId в раскрывающемся списке. Обработчики событий и Эффекты реагируют на изменения по-разному:

  • Логика внутри обработчиков событий не является реактивной. Код не перезапуститься пока пользователь не выполнит то же самое взаимодействие (т.е. клик) по кнопке. Обработчики событий считывают реактивные значения не “реагируя” на их изменения.
  • Логика внутри Эффектов реактивна. Если ваш Эффект считывает реактивное значение, то нужно добавить его в качестве зависимости. Тогда, если повторный рендер изменит это значение, то React перезапустит логику Эффекта с этим новым значением.

Вернёмся к предыдущему примеру, чтобы показать эту разницу.

Логика внутри обработчиков событий не является реактивной

Взгляните на эту строку кода. Должна ли эта логика быть реактивной?

// ...
sendMessage(message);
// ...

С точки зрения пользователя, изменение message не означает, что он хочет отправить сообщение. Это означает только то, что пользователь печатает. Другими словами, логика, которая отправляет сообщение, не должна быть реактивной. Она не должна запускаться снова только потому, что реактивное значение изменилось. Вот почему она находится внутри обработчика событий:

function handleSendClick() {
sendMessage(message);
}

Обработчики событий не являются реактивными, поэтому sendMessage(message) будет выполнен только при клике на кнопку “Отправить”.

Логика внутри Эффектов реактивна

Теперь взглянем на эти строки:

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...

С точки зрения пользователя, изменение roomId означает, что он хочет подключиться к другому чату. Другими словами, логика подключения к чату должна быть реактивной. Вы хотите, чтобы этот код “отставал” от реактивного значения и перезапускался при изменении этого значения. Поэтому он относится к Эффекту:

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);

Эффекты являются реактивными, поэтому createConnection(serverUrl, roomId) и connection.connect() будут запускаться для каждого нового значения roomId. Ваш Эффект синхронизирует соединение с выбранным в данный момент чатом.

Извлечение нереактивной логики из Эффектов

Все становится сложнее, когда вы хотите смешать реактивную логику с нереактивной логикой.

Например, представьте, что вы хотите показать уведомление, когда пользователь подключается к чату. Вы считываете текущую тему (тёмную или светлую) из пропсов, чтобы показать уведомление в правильном цвете:

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...

Однако theme — это реактивное значение (оно может измениться в результате повторного рендера), поэтому каждое реактивное значение внутри Эффекта нужно добавить в зависимости. Теперь придётся добавить theme в массив зависимостей Эффекта:

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ Все зависимости добавлены
// ...

Попробуйте найти проблему с пользовательским опытом в приведённом ниже примере:

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

Когда roomId меняется, чат переподключается, как и ожидалось. Но поскольку theme также является зависимостью, то при изменении темы чат также переподключается. Это не очень хорошо!

Иначе говоря, вы не хотите, чтобы эта часть кода внутри Эффекта была реактивной:

// ...
showNotification('Connected!', theme);
// ...

Вам нужен способ вынести эту нереактивную логику из реактивного Эффекта.

Объявление События эффекта

Under Construction

Этот раздел описывает экспериментальный API, который ещё не был выпущен в стабильной версии React.

Используйте специальный хук useEffectEvent, чтобы извлечь эту нереактивную логику из вашего Эффекта:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...

onConnected называется Событием эффекта. Это часть логики вашего Эффекта, но она ведёт себя скорее как обработчик событий. Логика внутри неё не реактивна, и она всегда «видит» последние значения ваших пропсов и состояния.

Теперь можно вызвать Событие эффекта onConnected внутри вашего Эффекта:

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Все зависимости добавлены
// ...

Это решает проблему. Обратите внимание, что вам пришлось удалить onConnected из списка зависимостей вашего эффекта. События эффекта не являются реактивными и должны быть исключены из зависимостей.

Убедитесь, что теперь всё работает так, как вы ожидаете:

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

Вы можете думать о Событиях эффектов как о чём-то очень похожем на обработчики событий. Главное отличие в том, что обработчики событий запускаются в ответ на взаимодействие с пользователем, тогда как События эффектов запускаются вами из Эффектов. События эффектов “разрывают связь” между реактивностью эффектов и кодом, который не должен быть реактивным.

Чтение последних пропсов и состояния с помощью Событий эффектов

Under Construction

Этот раздел описывает экспериментальный API, который ещё не был выпущен в стабильной версии React.

События эффектов позволяют исправить множество паттернов, в которых может возникнуть соблазн отключить линтер зависимостей.

Например, у вас есть Эффект, чтобы логировать страницу посещений:

function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}

Далее вы добавляете для вашего сайта многостраничность. Теперь ваш компонент Page получает проп url с текущим путём. Если вы хотите использовать url при вызове функции logVisit, то линтер зависимостей выдаст предупреждение:

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 В зависимостях React хука useEffect отсутствует: 'url'
// ...
}

Подумайте, что вы хотите от вашего кода. Вы хотите логировать посещения для разных URL, поскольку каждый URL представляет собой отдельную страницу. Другими словами, вызов logVisit должен быть реактивным по отношению к url. Вот почему в этом случае следует прислушаться к линтеру и добавить url в качестве зависимости:

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ Все зависимости добавлены
// ...
}

Теперь предположим, что вы хотите включать в логи количество товаров в корзине:

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 В зависимостях React хука useEffect отсутствует: 'numberOfItems'
// ...
}

Вы использовали numberOfItems внутри Эффекта, поэтому линтер просит добавить его как зависимость. Однако вы не хотите, чтобы вызов logVisit был реактивным по отношению к numberOfItems. Если пользователь кладёт что-то в корзину, и numberOfItems изменяется, это не значит, что пользователь снова посетил страницу. Другими словами, посещение страницы является, в некотором смысле, «событием». Оно происходит в определённый момент времени.

Разделим код на две части:

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
onVisit(url);
}, [url]); // ✅ Все зависимости добавлены
// ...
}

Здесь onVisit — это Событие эффекта. Код внутри него не реактивный. Вот почему вы можете использовать numberOfItems (или любое другое реактивное значение!), не беспокоясь о том, что это приведёт к повторному выполнению внешнего кода при изменениях.

С другой стороны, Эффект остаётся реактивным. Код внутри Эффекта использует свойство url, поэтому Эффект будет перезапускаться при изменении url. Это, в свою очередь, вызовет Событие эффекта onVisit.

В результате вы будете вызывать logVisit при каждом изменении url и всегда иметь актуальное numberOfItems. Однако, если numberOfItems изменится сам по себе, это не приведёт к повторному запуску кода.

Note

Возможно, вы зададитесь вопросом, можно ли вызвать onVisit() без аргументов и считать url внутри него:

const onVisit = useEffectEvent(() => {
logVisit(url, numberOfItems);
});

useEffect(() => {
onVisit();
}, [url]);

Это сработает, но лучше передать этот url в Событие эффекта явно. Передавая url в качестве аргумента в событие эффекта, вы говорите, что посещение страницы с другим url представляет собой отдельное «событие» с точки зрения пользователя. visitedUrl является частью произошедшего «события»:

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
onVisit(url);
}, [url]);

Поскольку Событие эффекта явно «запрашивает» visitedUrl, теперь вы не можете случайно удалить url из зависимостей эффекта. Если вы удалите зависимость url (что приведёт к тому, что отдельные посещения страницы будут засчитаны как одно), линтер предупредит вас об этом. Вам нужно, чтобы onVisit был реактивным по отношению к url, поэтому вместо того, чтобы читать url внутри (где он не будет реактивным), вы передаёте его из вашего Эффекта.

Это особенно важно, если внутри Эффекта есть асинхронная логика:

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
setTimeout(() => {
onVisit(url);
}, 5000); // Задержка при логировании посещений
}, [url]);

Здесь url внутри onVisit соответствует последнему значению url (которое уже могло измениться), а visitedUrl соответствует url, который изначально вызвал этот Эффект (и вызвал onVisit).

Deep Dive

Можно ли вместо этого игнорировать линтер зависимостей?

В коде вы иногда можете увидеть игнорирование правил линтера:

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Избегайте подобного игнорирования правил линтера:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}

После того как useEffectEvent станет стабильной частью React, мы рекомендуем никогда не подавлять линтер.

Первый недостаток при отключении этого правила заключается в том, что React перестанет предупреждать, чтобы вы добавили зависимости в свой Эффект при использовании новых реактивных значений Эффект. В предыдущем примере вы добавили url к зависимостям, потому что React напомнил вам сделать это. Вы больше не будете получать такие напоминания при редактировании этого Эффекта, если вы отключите линтер. Это ведёт к ошибкам.

Вот пример запутанной ошибки, вызванной отключением линтера. В примере функция handleMove должна считывать текущее значение переменной состояния canMove, чтобы решить, должна ли точка следовать за курсором. Однако canMove всегда остаётся равной true внутри handleMove.

Можете сказать почему?

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

Проблема заключается в отключении линтера зависимостей. Если вы включите линтер, то увидите, что этот Эффект должен зависеть от функции handleMove. Это имеет смысл: handleMove объявлен внутри тела компонента, что делает его реактивным значением. Каждое реактивное значение должно быть включено в зависимость, иначе оно может устареть!

Автор исходного кода «солгал» React’у, сказав, что Эффект не зависит ([]) от каких-либо реактивных значений. Вот почему React не перезапустил Эффект после изменения canMovehandleMove вместе с ним). Поскольку React не перезапустил Эффект, handleMove, присоединённый в качестве слушателя, является функцией handleMove, созданной во время первого рендера. Во время первого рендера canMove был true, поэтому handleMove из первого рендера всегда будет видеть это значение.

Если вы никогда не отключаете линтер, вы никогда не увидите проблем с устаревшими значениями.

При использовании useEffectEvent нет необходимости «лгать» линтеру, и код будет работать так, как ожидалось:

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  const onMove = useEffectEvent(e => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

Это вовсе не значит, что useEffectEvent — это всегда правильное решение. Вы должны применять его только к тому коду, который не должен быть реактивным. В примере выше вы не хотели, чтобы код Эффекта был реактивным по отношению к canMove. Вот почему имело смысл вынести Событие Эффекта.

Почитайте об удалении зависимостей Эффекта, чтобы узнать о других альтернативах отключению линтера.

Ограничения при использовании Эффектов событий

Under Construction

Этот раздел описывает экспериментальный API, который ещё не был выпущен в стабильной версии React.

Использование Эффектов событий накладывает ряд ограничений:

  • Вызывайте их только внутри Эффектов.
  • Никогда не передавайте их в другие компоненты или хуки.

Например, не объявляйте и не передавайте Событие эффекта следующим образом:

function Timer() {
const [count, setCount] = useState(0);

const onTick = useEffectEvent(() => {
setCount(count + 1);
});

useTimer(onTick, 1000); // 🔴 Избегай передачи Эффекта событий

return <h1>{count}</h1>
}

function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Необходимо указать "колбэк" в качестве зависимости
}

Вместо этого всегда объявляйте События эффектов рядом с Эффектами, которые их используют:

function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}

function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});

useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Принято: вызывается локально только внутри эффекта
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // Не нужно определять "onTick" (Событине эффекта) в качестве зависимости
}

События эффекта — это не реактивные «части» кода вашего Эффекта. Они должны быть рядом с Эффектом, который их использует.

Recap

  • Обработчики событий запускаются в результате определённых взаимодействий.
  • Эффекты запускаются всякий раз, когда требуется синхронизация.
  • Логика внутри обработчиков событий не является реактивной.
  • Логика внутри Эффектов является реактивной.
  • Вы можете вынести нереактивную логику из Эффектов в События эффектов.
  • Вызывайте События эффектов только внутри Эффектов.
  • Не передавайте События эффектов другим компонентам или хукам.

Challenge 1 of 4:
Исправьте переменную, которая не обновляется

В компоненте Timer задано состояние count, которое увеличивается каждую секунду. Значение, на которое она увеличивается, хранится в переменной состояния increment. Вы можете управлять переменной increment с помощью кнопок плюс и минус.

Однако, сколько бы раз вы ни нажимали кнопку «плюс», счётчик всё равно увеличивается на единицу каждую секунду. Что не так с этим кодом? Почему increment всегда равен 1 внутри Эффекта? Найдите ошибку и исправьте её.

import { useState, useEffect } from 'react';

export default function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + increment);
    }, 1000);
    return () => {
      clearInterval(id);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <h1>
        Counter: {count}
        <button onClick={() => setCount(0)}>Reset</button>
      </h1>
      <hr />
      <p>
        Every second, increment by:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}></button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}