Проверьте: массив зависимостей у первого эффекта, отсутствие clearInterval, устаревшее замыкание на seconds внутри интервала.
Разбор ошибок
Что сломано
Первый useEffect без массива зависимостей запускается после каждого рендера. setUser вызывает ререндер → снова fetch → бесконечный цикл запросов.
setInterval без clearInterval — при размонтировании (или смене deps) таймер продолжает жить. Утечка + setState на размонтированном компоненте.
Устаревшее замыкание (stale closure).setSeconds(seconds + 1) захватывает seconds из первого рендера (всегда 0), т.к. эффект с [] создаёт колбэк один раз. Счётчик застрянет на 1.
Нет обработки гонки запросов. При быстрой смене userId ответы могут прийти не по порядку — покажется не тот пользователь.
Решение
Исправленный компонент
function UserWidget({ userId }) { const [user, setUser] = useState(null); const [seconds, setSeconds] = useState(0); // грузим при смене userId; отменяем устаревшие ответы useEffect(() => { let cancelled = false; fetch(`/api/users/${userId}`) .then((r) => r.json()) .then((data) => { if (!cancelled) setUser(data); }); return () => { cancelled = true; // игнорируем ответ, если userId сменился }; }, [userId]); // ← зависимость от userId // один интервал на всё время жизни, с очисткой useEffect(() => { const id = setInterval(() => { // функциональный апдейт — не зависим от старого seconds setSeconds((prev) => prev + 1); }, 1000); return () => clearInterval(id); // cleanup }, []); return ( <div> <p>{user?.name}</p> <p>On page: {seconds}s</p> </div> );}
Ключевые принципы
Массив зависимостей определяет, когда эффект перезапускается. Пустой [] — один раз; отсутствие массива — каждый рендер.
Любая подписка/таймер требует cleanup в возвращаемой функции.
Внутри интервала/подписки используйте функциональный апдейтsetState(prev => ...), чтобы не поймать stale closure.
Для запросов защищайтесь от гонки флагом cancelled (или AbortController).