Why useEffect runs twice in Next.js dev
If your logs, API calls, or subscriptions fire twice in local dev, you are probably seeing React's development-only Strict Mode check. Here is what to fix and what not to panic about.
Why useEffect runs twice in Next.js dev#
You add a console.log, start next dev, and the effect fires twice. Sometimes that means two fetches, two analytics pings, or two subscriptions.
The root cause is usually not Next.js. React documents this behavior directly: when Strict Mode is on, React runs one extra development-only setup and cleanup cycle before the real setup.
That means the question is not "how do I stop React from doing that?" The question is "why does my effect break when React stress-tests it?"
The broken effect pattern#
This is the classic example:
'use client'
import { useEffect, useState } from 'react'
export default function UserList() {
const [users, setUsers] = useState<string[]>([])
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => setUsers(data))
}, [])
return <pre>{JSON.stringify(users, null, 2)}</pre>
}
In development, you may see two requests. The effect has no cleanup and no cancellation, so the extra Strict Mode cycle reveals that weakness immediately.
A safer effect#
'use client'
import { useEffect, useState } from 'react'
export default function UserList() {
const [users, setUsers] = useState<string[]>([])
useEffect(() => {
const controller = new AbortController()
async function loadUsers() {
const res = await fetch('/api/users', { signal: controller.signal })
const data = await res.json()
setUsers(data)
}
loadUsers().catch((error) => {
if (error.name !== 'AbortError') throw error
})
return () => controller.abort()
}, [])
return <pre>{JSON.stringify(users, null, 2)}</pre>
}
Now the cleanup mirrors the setup. That is exactly what the React docs want Strict Mode to test.
What React is actually telling you#
If a second setup+cleanup cycle causes visible breakage, your effect is probably doing one of these:
- opening a subscription and never unsubscribing
- firing a network request with no cancellation
- mutating global state during setup
- using an effect for something that should happen in response to a user event instead
The better fix in many Next.js apps: move the fetch to the server#
In App Router projects, a lot of client useEffect fetching should not be client fetching at all.
If the data is needed to render the page, fetch it in the Server Component page or loader path first. That removes the whole class of duplicate dev-fetch confusion.
// app/users/page.tsx
export default async function Page() {
const res = await fetch('https://example.com/api/users', {
cache: 'no-store',
})
const users = await res.json()
return <pre>{JSON.stringify(users, null, 2)}</pre>
}
That is not always the right move, but it is often the right default in modern Next.js.
What not to do first#
Do not start by disabling Strict Mode just because the effect is noisy. If the extra cycle exposes a bug, production can still hit the same bug through remounts, navigation, interrupted renders, or subscription leaks.
Use Strict Mode as the warning sign it is meant to be.
When a useRef guard is acceptable#
For genuinely one-off client actions that cannot be moved elsewhere, a guard can be acceptable:
'use client'
import { useEffect, useRef } from 'react'
export default function ClientPing() {
const sent = useRef(false)
useEffect(() => {
if (sent.current) return
sent.current = true
void fetch('/api/ping', { method: 'POST' })
}, [])
return null
}
This is a tactical fix, not the first tool to reach for. Prefer clean setup/cleanup or server-side data flow when possible.
For related React and App Router debugging:
- React Server Components Deep Dive
- Window is not defined in Next.js – 2026 Fix for React Apps
- Next.js Hydration Mismatch Error: Exact Fixes for App Router and React
- Next.js Performance Optimization for Indie Developers
References#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Next.js Redirect from / to another page
If your redirect works only after render, flickers, or fires in the wrong place, you are probably using the wrong redirect API for the job. Here is the exact mapping.
How to get query string parameters in Next.js
If `router.query` examples keep breaking on you, the problem is usually that you're mixing App Router and Pages Router APIs. Here is the exact fix for each case.
Next.js 15 vs 14: Real Benchmarks and Whether to Upgrade (2026)
Side-by-side benchmarks on real apps — build time, bundle size, runtime perf, and the breaking changes that hurt. The honest verdict on whether the upgrade is worth it.
Browse by Topic
Find stories that matter to you.
