Перейти к содержимому

Шаг 4. Ссылки и оптимизация (useRef, useCallback, memo)

Приложение растёт, список длинный — пора подумать о рендерах. На этом шаге: автофокус на поле ввода через useRef, подсветка изменения количества через usePrevious, и оптимизация строк списка через React.memo + useCallback.

  • После добавления задачи поле ввода должно сохранять фокус (через useRef на input);
  • покажи, на сколько изменилось число задач с прошлого рендера (хук usePrevious, построенный на useRef);
  • вынеси строку задачи в отдельный компонент TaskItem, оберни его в React.memo;
  • обработчики onToggle/onRemove, передаваемые в TaskItem, оберни в useCallback, чтобы memo реально работал и строки не перерисовывались при каждом рендере родителя.
  • useRef<HTMLInputElement>(null) — ссылка на DOM-узел. Меняем фокус императивно (inputRef.current?.focus()) — это не состояние, ре-рендер не нужен.
  • useRef для мутабельного значенияusePrevious хранит предыдущее значение между рендерами без ре-рендера: записываем в ref.current внутри useEffect, возвращаем то, что было до обновления.
  • React.memo(Component) — пропускает ре-рендер компонента, если пропсы не изменились по ссылке. Нужен для дорогих/многочисленных строк списка.
  • useCallback(fn, deps) — стабилизирует ссылку на функцию между рендерами. Без него каждый рендер родителя создаёт новый onToggle, и React.memo у TaskItem бесполезен (пропс-функция всегда «новая»).
Показать эталонную реализацию
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { Task } from '../types/task';
// usePrevious — хранит предыдущее значение через useRef (без ре-рендера)
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// Строка задачи — мемоизирована: ре-рендер только если изменились её пропсы
interface ItemProps {
task: Task;
onToggle: (id: string) => void;
onRemove: (id: string) => void;
}
const TaskItem = memo(function TaskItem({ task, onToggle, onRemove }: ItemProps) {
return (
<li>
<input
type="checkbox"
checked={task.done}
onChange={() => onToggle(task.id)}
/>
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
{task.title}
</span>
<button onClick={() => onRemove(task.id)}>×</button>
</li>
);
});
export function TaskApp() {
const [tasks, setTasks] = useState<Task[]>([]);
const [draft, setDraft] = useState('');
const inputRef = useRef<HTMLInputElement>(null); // ссылка на input
const prevCount = usePrevious(tasks.length);
const delta =
prevCount === undefined ? 0 : tasks.length - prevCount;
// useCallback: стабильные ссылки → React.memo у TaskItem работает
const addTask = useCallback((title: string) => {
const trimmed = title.trim();
if (!trimmed) return;
setTasks((prev) => [
...prev,
{ id: crypto.randomUUID(), title: trimmed, done: false, createdAt: Date.now() },
]);
setDraft('');
inputRef.current?.focus(); // вернуть фокус после добавления
}, []);
const toggleTask = useCallback((id: string) => {
setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}, []);
const removeTask = useCallback((id: string) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<section>
<form
onSubmit={(e) => {
e.preventDefault();
addTask(draft);
}}
>
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Новая задача..."
/>
<button type="submit">Добавить</button>
</form>
{delta !== 0 && <small>Изменение: {delta > 0 ? `+${delta}` : delta}</small>}
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onRemove={removeTask}
/>
))}
</ul>
</section>
);
}

Дальше — Шаг 5: сложное состояние через useReducer и useContext.