An interface can look correct on its first render and still contain a bug that only appears after sorting a list, navigating between accounts, or submitting a modified form. Those failures are more useful to study than a checklist of rules such as “never use Context” or “always fetch on the server.”

The five mistakes below have different causes: duplicated state, incorrect component identity, an overly broad client boundary, unclear caching assumptions, and missing authorization. Each section gives you a symptom to look for and a way to check the fix.

Scope: The App Router examples use the Next.js 15 model (the portfolio currently depends on Next.js 15.5.28) and React 18-compatible APIs. Some Next.js 16 applications opt into Cache Components, which uses a different caching model. Code involving application-specific data access is explicitly illustrative.

1. Using an Effect to calculate state you already have

Symptom: A cart total starts at zero or lags behind the latest change, even though all line items are already available.

The following Client Component is complete enough to place in an App Router project. Its inputs use integer cents for clarity; a real checkout must validate prices and quantities on the server rather than trusting browser-provided amounts.

// app/cart/order-total.tsx: redundant derived state
'use client'

import { useEffect, useState } from 'react'

type Line = { priceCents: number; quantity: number }

export default function OrderTotal({ items }: { items: Line[] }) {
  const [totalCents, setTotalCents] = useState(0)

  useEffect(() => {
    setTotalCents(items.reduce(
      (sum, line) => sum + line.priceCents * line.quantity, 0
    ))
  }, [items])

  return <output>{totalCents} cents</output>
}

On the first commit, this component uses the initial total of zero. After committing, the Effect calculates the value and schedules another update. The stale intermediate result might not be visible in every interaction, but it is still redundant work and a second source of truth.

Calculate the total directly while rendering:

// app/cart/order-total.tsx: derived value
'use client'

type Line = { priceCents: number; quantity: number }

export default function OrderTotal({ items }: { items: Line[] }) {
  const totalCents = items.reduce(
    (sum, line) => sum + line.priceCents * line.quantity, 0
  )

  return <output>{totalCents} cents</output>
}

This is not a warning against all Effects. Effects synchronize with external systems such as subscriptions, timers, and imperative browser APIs. User-triggered actions generally belong in event handlers. An expensive pure calculation may justify memoization after measurement, but it does not need to become separate state.

Check the fix: Pass [{ priceCents: 125, quantity: 2 }] to the component and expect 250 cents on its initial render. Change the quantity to three and expect 375 cents on the next render without an Effect-driven update. The principle is explained in React's You Might Not Need an Effect.

2. Using a list position as a component's identity

Symptom: After sorting or removing a row, text entered in an input appears beside a different item.

React uses key to decide which child represents the same component across renders. With key={index}, identity follows the position, not the underlying record. If a row component owns its own draft state, moving records can associate that draft with the wrong record.

Here is a self-contained Client Component for a small experiment. Type different text in the two inputs and reverse the order:

// app/key-demo/page.tsx
'use client'

import { useState } from 'react'

type Todo = { id: string; label: string }
const initialTodos: Todo[] = [
  { id: 'a', label: 'First task' },
  { id: 'b', label: 'Second task' },
]

function TodoRow({ todo }: { todo: Todo }) {
  const [draft, setDraft] = useState('')
  return (
    <li>
      <label>
        {todo.label}
        <input
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
        />
      </label>
    </li>
  )
}

export default function KeyDemo() {
  const [todos, setTodos] = useState(initialTodos)
  return (
    <main>
      <h1>List key identity</h1>
      <button type="button" onClick={() => setTodos(
        (current) => [...current].reverse()
      )}>
        Reverse order
      </button>
      <ul>
        {todos.map((todo) => <TodoRow key={todo.id} todo={todo} />)}
      </ul>
    </main>
  )
}

In this version, key={todo.id} follows the record. Replace that expression with key={index} (and add index to the map callback) to reproduce the bug. React preserves the state of the component at each position even after the record shown there changes.

The ID must be stable for that record. Generating a new UUID or using Math.random() during rendering makes React replace children and lose their local state. An index can be acceptable for a genuinely static list that never reorders, inserts, or removes records. React explains this in its list key rules.

Check the fix: Type different drafts in the two inputs, then select Reverse order. With stable IDs, the draft follows its record. With index keys, the draft follows its position. To explore deletion or insertion, add buttons that change the todos array and repeat the same check.

3. Making an entire App Router page a Client Component

Symptom: You add 'use client' to a page just to support one filter button, then move its initial data loading into an Effect.

This expands the client module boundary more than necessary. A Client Component is appropriate for state, handlers, and browser APIs, but it does not follow that its parent page must also be a Client Component. In the App Router, pages and layouts are Server Components by default.

For a projects page, let the server load authorized, display-safe data, then hand an interactive subset to a small Client Component:

// app/projects/page.tsx: Server Component (illustrative).
import ProjectFilter from './project-filter'
import { getVisibleProjects } from '@/lib/projects'

export default async function ProjectsPage() {
  // Assumes this server-only function authenticates the caller
  // and only returns records they may view.
  const projects = await getVisibleProjects()
  const items = projects.map(({ id, name, archived }) => ({
    id, name, archived
  }))

  return (
    <main>
      <h1>Projects</h1>
      <ProjectFilter items={items} />
    </main>
  )
}
// app/projects/project-filter.tsx: Client Component.
'use client'

import { useState } from 'react'

type Project = { id: string; name: string; archived: boolean }

export default function ProjectFilter({ items }: { items: Project[] }) {
  const [showArchived, setShowArchived] = useState(false)
  const visible = items.filter((item) => showArchived || !item.archived)

  return (
    <section>
      <label>
        <input
          type="checkbox"
          checked={showArchived}
          onChange={(event) => setShowArchived(event.target.checked)}
        />
        Show archived
      </label>
      <ul>
        {visible.map((item) => <li key={item.id}>{item.name}</li>)}
      </ul>
    </section>
  )
}

The getVisibleProjects module is an application-specific integration point, not a runnable function provided by this article. It must authenticate the caller and restrict which projects that caller can view. Its implementation depends on the application's session provider and database, so inventing one here would suggest security guarantees the example cannot establish. Never serialize private fields to a Client Component and assume they are hidden because they were originally fetched on the server.

Avoid calling your own Route Handler with fetch('/api/projects') from a Server Component as a shortcut for direct database access. A relative URL is not a universal server-side fetch target, and the extra HTTP hop is usually unnecessary when both pieces run on the server. External APIs and genuinely browser-driven interactions are different cases. Client fetching is sometimes the right choice for live updates, user-triggered queries, and browser-local state.

Check the fix: Inspect the Client Component boundary and its imports, check that only permitted serializable props cross it, and confirm that first-load content is present without waiting for a client Effect. The Next.js Server and Client Components guide describes the boundary. For a fuller architecture discussion, see React Server Components for Skeptics.

4. Assuming a successful fetch tells you how fresh a page is

Symptom: Data updates in the backend, but one page still shows old values, or a request you expected to be cached runs again.

A fetch response, the rendered route output, and the client-side Router Cache are different layers. Treating them as one cache leads to incorrect fixes.

In Next.js 15, a server fetch without explicit caching does not enter the persistent Data Cache by default. An uncached fetch also opts that route out of the Full Route Cache: the same route should not be described as statically prerendered merely because another route is. The client-side Router Cache is a separate layer and can still reuse shared layouts or previously visited page content during navigation. Explicit cache: 'force-cache' or next.revalidate changes the intended fetch freshness policy.

For public data, make the choice visible at the call site:

// Illustrative options, not real API endpoints.
await fetch(catalogUrl, { next: { revalidate: 300 } }) // Shared data with a freshness window.
await fetch(liveStatusUrl, { cache: 'no-store' })       // Request fresh data.

Do not put authorization-dependent responses into a broadly shared cache. For private records, reason about the caller, scope, cache keys, and invalidation rather than copying a public catalog example.

Version boundary: The Next.js 15 caching guide documents the Data Cache, Full Route Cache, and Router Cache separately. Next.js 16 also supports Cache Components; when enabled, use cache and cacheLife replace some older route-segment configuration patterns. Do not paste one version's settings into another without checking the actual project configuration.

Check the fix: Test in a production build where possible. Record whether the request reaches the data source, whether a full reload differs from client navigation, and whether a write invalidates the specific view. A development HMR refresh is not a reliable cache test because development has separate reuse behavior.

5. Assuming a Server Action is private because it runs on the server

Symptom: A signed-in user can submit another record's ID and update data they do not own.

A Server Action is server-side code, but its client-callable entry point can be invoked by an HTTP request. Hiding its form or protecting the page that renders the form does not authorize the underlying operation.

For example, this query uses an ID supplied by the browser but never limits the update to the verified user:

-- Unsafe if project_id came from an untrusted form.
UPDATE projects SET name = :new_name
WHERE id = :project_id;

For a simple single-owner model, derive verified_actor_id from a trusted server-side session, validate the submitted fields, and constrain the mutation:

-- Illustrative predicate; placeholder syntax varies by database driver.
UPDATE projects SET name = :new_name
WHERE id = :project_id
  AND owner_id = :verified_actor_id;

Then check the affected-row count and handle the zero-row case without disclosing another user's record. In teams or multi-tenant applications, ownership alone may be insufficient: the query must enforce the actual membership or permission policy.

Check the fix: Seed two user accounts and two records. Verify that user A can change their own record, cannot change user B's record even after modifying the submitted ID, and that B's stored row remains unchanged. Repeat for reads and deletes. Next.js treats Server Actions as exposed endpoints; the deeper Server Action authorization walkthrough covers the query-level test.

A practical review order

When a React or Next.js feature misbehaves, start with the failure rather than adding another abstraction:

What you observeWhat to inspect first
A value briefly renders staleDerived state and Effects that set it
Input state follows the wrong rowStable list keys and component identity
Initial content depends on a browser requestServer/Client boundary and data source
Values differ after refresh or navigationFetch policy, route rendering, and invalidation
A user can affect another user's recordObject-level authorization in the server operation

Prop drilling is not automatically a bug, Context is not automatically faster, and client-side fetching is not automatically wrong. Likewise, extracting every function into its own file does not make an application easier to maintain. The valuable distinction is whether an abstraction prevents a specific failure under the application's constraints.

For a final smoke test, reorder an editable list, load a route with JavaScript disabled, test a data update across full reload and client navigation, and attempt an unauthorized mutation with a second account. Those checks are more revealing than a generic “best practices” checklist.

This article provides documented patterns and diagnostic procedures, not a claim that the illustrative Next.js application was built or benchmarked as a complete project.