🔍 TaskPane Flicker Investigation

A deep dive into debugging React + Next.js App Router re-render issues

January 4, 2026 | notion-clone project

Problem → Solution → Impact

Problem

  • TaskPane opened twice and flickered; RunningTasksBar re-rendered on close.
  • Issue persisted post-Zustand migration; root cause unclear.
  • Next.js App Router navigation side effects suspected.

Solution

  • Hypothesis-driven investigation across race conditions, pathname sync, and basePath.
  • Instrumented renders/logs to trace remounts and failed fetches.
  • Identified router.replace() to a non-existent route; switched to history.replaceState().

Impact

  • Eliminated double-open/close flicker; SPA feel restored.
  • Clear, documented root cause for future routing work.
  • Baseline for further perf tuning without regressions.
UI stability
Before: visible flicker After: smooth open/close
Re-render churn
Before: remounts on replace() After: stable component tree
Debugging clarity
Before: multiple hypotheses After: single documented fix
Reproduce & instrument

Capture render logs and network traces to see when components remount.

Test hypotheses

Race conditions, pathname sync, Zustand isolation, basePath — ruled out systematically.

Ship the fix

Replace router.replace() with history.replaceState() to keep SPA state intact.

Impact spotlight
  • Smoother UX in the notion-clone task board.
  • Actionable lesson: avoid router navigation to non-existent routes.
  • Reusable debugging pattern for future Next.js issues.
ISSUE

The Problem

🐛
Symptoms Observed
  • Open task: TaskPane flickers (appears to open twice)
  • Close task: RunningTasksBar flickers/re-renders
  • Visual effect: Noticeable UI flashing during open/close
  • Context: Issue persisted after Zustand migration
HYPOTHESIS 1

Race Condition Between State and URL

🤔
Probable Cause
Initial Theory

When user clicks a task, both setActiveTaskId and router.replace run. The URL sync effect then re-sets activeTaskId, causing a double update.

// The problematic flow: handleOpenTask(id) { setActiveTaskId(id); // First state update router.replace('/task/' + id); // Triggers URL change } useEffect(() => { if (pathname.includes('/task/')) { setActiveTaskId(urlTaskId); // Second state update! } }, [pathname]);
💡
Proposed Solution: Guard with intentRef
Option B

Add an intentRef to track user-initiated actions and skip URL sync during active transitions.

const intentRef = useRef(null); handleOpenTask(id) { intentRef.current = "opening"; setActiveTaskId(id); router.replace(taskHrefFor(id)); } useEffect(() => { if (intentRef.current) { // Skip sync while intent is active if (intentRef.current === "opening" && urlHasTask) { intentRef.current = null; } return; } // ... normal sync logic }, [pathname]);
Result
Did Not Fix

Close worked. Open still flickered. RunningTasksBar still flickered on both. This indicated the race condition was a symptom, not the root cause.

HYPOTHESIS 2

usePathname Causes Re-renders

🤔
Probable Cause
Deep Investigation

usePathname() triggers re-renders when URL changes. 7+ components use it via useTaskHref:

  • Board.tsx, RunningTasksBar.tsx, Column.tsx, TaskPane.tsx
  • GlobalTaskSearch.tsx, CommandPalette.tsx, SubtaskItem.tsx
💡
Proposed Solution: Isolate URL Sync
Architecture Change

Create TaskUrlSync component outside Board tree to handle URL sync, move activeTaskId to Zustand.

Result
Did Not Fix

Isolation didn't help because something else was causing Board to completely remount.

HYPOTHESIS 3

Zustand Store Isolation

🤔
Probable Cause
Architecture Theory

Maybe Zustand state updates were still cascading re-renders through the component tree.

💡
Proposed Solution: Full Zustand Migration
Full Migration

Move activeTaskId entirely to Zustand, Board reads only from store, no usePathname anywhere.

Result
Did Not Fix

Still flickered! Time to add debug logging to trace actual render sequence.

HYPOTHESIS 4

Move basePath to Zustand

🤔
Probable Cause
useTaskHref Issue

Child components use useTaskHref which internally uses usePathname. Store basePath in Zustand once.

Result
Did Not Fix

STILL FLICKERED! We were treating symptoms, not the disease. Debug logs revealed the truth...

🏆 FINAL BOSS

The Actual Root Cause & Solution

🔥
Debug Log Analysis

Adding console.log to track render counts revealed the smoking gun:

[Board] handleOpenTask called {id: 'cmjxvq...'}
[Board] render #3 {activeTaskId: 'cmjxvq...', pathname: '/'}
[TaskPane] render #5 {taskId: 'cmjxvq...'}
[Board] render #1 {activeTaskId: null, pathname: '/task/...'} ← REMOUNT!
[RunningTasksBar] render #1 ← REMOUNT!
[TaskPane] render #1 {taskId: null} ← REMOUNT!

The render counts reset to #1! Components were UNMOUNTED and REMOUNTED.

🎯
THE ACTUAL ROOT CAUSE

router.replace('/task/:id') causes Next.js to attempt navigation to a route that doesn't exist.

  • router.replace triggers Next.js to fetch server component at /task/[id]
  • Route doesn't exist → fetch fails
  • Next.js re-renders parent page → Server Component re-executes
  • React unmounts old Board, mounts fresh Board with reset state
  • URL sync effect then sets state again → "second open"
Fetch failed loading: GET "http://localhost:3000/task/...?_rsc=1qsp7"
THE BOSS SOLUTION

Use window.history.replaceState() instead of router.replace()

Aspectrouter.replace()window.history.replaceState()
Triggers Next.js navigation✗ Yes✓ No
Server fetch✗ Yes (fails)✓ None
Component remount✗ Yes✓ None
Updates browser URL✓ Yes✓ Yes
Shareable/bookmarkable✓ Yes✓ Yes
SPA experience✗ Broken✓ Perfect
// THE FIX - Board.tsx // Before (causes remount): handleOpenTask(id) { setActiveTaskId(id); router.replace(taskHrefFor(id), { scroll: false }); // ❌ } // After (pure SPA): handleOpenTask(id) { setActiveTaskId(id); window.history.replaceState(null, '', taskHrefFor(id)); // ✅ } // Handle browser back/forward useEffect(() => { const handlePopState = () => { const pathname = window.location.pathname; // Parse and sync... }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Handle initial deep link load (wait for tasks) useEffect(() => { if (initializedRef.current) return; if (Object.keys(tasks).length === 0) return; // Wait for tasks const pathname = window.location.pathname; // Parse URL and set activeTaskId if present initializedRef.current = true; }, [tasks]);
Final Result
  • Open task: Instant, no flicker ✓
  • Close task: Instant, no flicker ✓
  • RunningTasksBar: No unnecessary re-renders ✓
  • Deep linking: Works (copy URL → paste in new tab) ✓
  • Browser back/forward: Works correctly ✓

True SPA behavior achieved.

SUMMARY

Key Takeaways

#HypothesisSolution TriedResult
1Race condition (state & URL)Guard effect with intentRef❌ Partial
2usePathname causes re-rendersIsolate URL sync❌ Failed
3Zustand updates cascadeMove activeTaskId to Zustand❌ Failed
4useTaskHref uses usePathnameMove basePath to Zustand❌ Failed
🏆router.replace triggers remountUse window.history.replaceState✓ Success!

Lesson learned: When debugging re-render issues in Next.js App Router, check if your navigation method is causing server component re-execution. Adding render count logging early would have revealed the remount pattern sooner.