useState или useRef — в чём разница, и почему счётчик стоит на нуле
Ты делаешь счётчик лайков. Нейросеть где-то увидела useRef и написала так:
const likes = useRef(0);
<button onClick={() => { likes.current += 1; console.log(likes.current); }}>
♥ {likes.current}
</button>
Жмёшь пять раз. В консоли честно: 1, 2, 3, 4, 5. На кнопке — 0. Потом ты открываешь соседнее меню, и на кнопке вдруг появляется 5.
Ничего не сломалось. Просто useRef и useState оба «запоминают значение», но договариваются с экраном по-разному. Разберём.
Главная разница одной фразой
- useState — это табло на стене. Поменял значение — React перерисовывает компонент, и все видят новое.
- useRef — это блокнот в кармане. Записал — значение сохранилось, но экран об этом не узнал. Перерисовки нет.
В примере выше ты писал в блокнот. Число росло, а табло никто не обновлял. Когда меню вызвало перерисовку по своим причинам, React заодно прочитал блокнот — и показал 5.
Сравним по пунктам
Перерисовка.
- useState:
setLikes(5)запускает новый рендер. - useRef:
likes.current = 5не запускает ничего.
Когда видно новое значение.
- useState: только на следующем рендере. Сразу после
setLikes(likes + 1)строкаconsole.log(likes)покажет старое число. Это не баг, это снимок текущего рендера — подробнее в статье про состояние. - useRef: мгновенно. Записал в
.current— следующая строка уже видит новое.
Что хранить.
- useState: всё, что пользователь видит — текст, счётчики, открыто ли окно, выбранная вкладка.
- useRef: всё, что экрану не нужно — id таймера, ссылку на DOM-элемент, прошлое значение, контроллер отмены запроса.
Чтение во время рендера.
- useState: читай сколько угодно, для этого он и есть.
- useRef: в JSX его лучше не читать. Документация React прямо просит не трогать
.currentво время рендера — именно из-за истории со счётчиком выше.
Переживает ли перерисовку.
- Оба — да. React хранит их между рендерами.
- А вот обычная переменная через
letвнутри компонента — нет. Каждый рендер вызывает функцию компонента заново, иletначинается с нуля.
Где useRef незаменим: таймер, который не останавливается
Вот обратная ловушка. Ты делаешь секундомер и хранишь id таймера в обычной переменной:
let timerId;
const start = () => { timerId = setInterval(tick, 1000); };
const stop = () => clearInterval(timerId);
Жмёшь «Старт» — секунды бегут. Жмёшь «Стоп» — бегут дальше. Каждая секунда вызывает tick, tick меняет состояние, компонент перерисовывается — и let timerId создаётся заново, пустым. stop очищает undefined. Настоящий таймер живёт сам по себе.
Положи id в useState — заработает, но каждый setTimerId вызовет лишнюю перерисовку ради числа, которое никто не видит.
Правильный ответ — блокнот:
const timerId = useRef(null);
const start = () => { timerId.current = setInterval(tick, 1000); };
const stop = () => clearInterval(timerId.current);
Значение переживает рендеры и не трогает экран. Ровно то, что нужно.
Ещё один приём: самое свежее значение в отложенном коде
Пользователь печатает заметку. Через три секунды после нажатия «Отправить» ты сохраняешь черновик: setTimeout(() => save(text), 3000). За эти три секунды человек дописал ещё строчку. А сохранилась — старая версия.
Функция таймера запомнила text того рендера, в котором её создали. Новое значение появилось уже в следующем рендере, а таймер про него не знает.
Блокнот решает и это:
const textRef = useRef(text);
useEffect(() => { textRef.current = text; }, [text]);
setTimeout(() => save(textRef.current), 3000);
Внутри таймера ты читаешь блокнот, а не снимок. И получаешь то, что лежит там прямо сейчас. Обрати внимание: читаешь .current в колбэке, а не в JSX — правило из раздела выше не нарушено.
Кому что подойдёт
Задай себе один вопрос: если значение поменялось, должен ли пользователь это увидеть?
- Да →
useState. Лайки, текст в поле, открытое меню, загрузка. - Нет →
useRef. Таймеры, DOM-элементы, «было ли уже отправлено», прошлое значение для сравнения.
Сомневаешься — бери useState. Лишняя перерисовка почти никогда не заметна. А значение, которое не появилось на экране, — заметно сразу и выглядит как баг.
Если просишь React-код у нейросети, задай правило явно:
добавь секундомер со старт и стопДобавь в React-компонент секундомер с кнопками Старт и Стоп. Прошедшие секунды храни в useState — они на экране. Id таймера храни в useRef — его не видно, и он не должен вызывать перерисовку. Не читай ref.current в JSX. Очисти таймер в cleanup у useEffect, чтобы он не жил после размонтирования.Можно ли заменить useState на useRef ради скорости?
Нет, если значение выводится на экран. Ты сэкономишь перерисовку, но экран начнёт врать. Оптимизировать стоит, только когда профилировщик показал реальную проблему.
Зачем useRef для DOM-элементов?
Чтобы обратиться к конкретному элементу: поставить фокус в поле, прокрутить к блоку, измерить размер. React сам кладёт элемент в .current, если передать ref в атрибут ref.
Короткие уроки-истории, симулятор агента и ежедневная практика — в нашем мобильном приложении. Бесплатно.