Skip to content

⚡ Bolt: Fix search debounce to prevent redundant URL updates and renders#156

Open
anyulled wants to merge 1 commit into
mainfrom
bolt/fix-search-debounce-6944799865442708773
Open

⚡ Bolt: Fix search debounce to prevent redundant URL updates and renders#156
anyulled wants to merge 1 commit into
mainfrom
bolt/fix-search-debounce-6944799865442708773

Conversation

@anyulled
Copy link
Copy Markdown
Owner

@anyulled anyulled commented Apr 12, 2026

💡 What: Refactored the search input debouncing in TalksFilterBar.tsx to use a useRef to store and clear the timeout ID.
🎯 Why: The previous implementation incorrectly returned a cleanup function directly from the onChange event handler. React does not execute cleanup functions returned from event handlers (unlike useEffect). This resulted in the timeout never being cleared, meaning every single keystroke triggered a state update and URL parameter change after 300ms, defeating the purpose of the debounce and causing excessive re-renders and history stack bloat.
📊 Impact: Reduces redundant React re-renders and Next.js router navigations (router.push) by ~90% when a user types a multi-character search query. For a 10-character search, it now fires 1 update instead of 10.
🔬 Measurement: Type rapidly in the search bar on the Talks page. Observe the Network tab or React Profiler; URL updates and re-renders will now only occur once the user stops typing for 300ms, rather than on every keystroke.


PR created automatically by Jules for task 6944799865442708773 started by @anyulled

Summary by CodeRabbit

  • Bug Fixes

    • Corrected search debouncing in the talks filter to ensure proper request timing and resource cleanup when navigating away from the component.
  • Documentation

    • Added internal technical notes on React debouncing patterns and event handler cleanup best practices.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@qodo-code-review
Copy link
Copy Markdown

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Review Summary by Qodo

Fix search debounce anti-pattern in TalksFilterBar component

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Fixed search debounce implementation in TalksFilterBar.tsx using useRef
• Prevents redundant URL updates and re-renders on every keystroke
• Properly clears timeout before setting new one in event handler
• Documented anti-pattern and solution in project learning notes
Diagram
flowchart LR
  A["Event Handler<br/>handleSearchChange"] -->|"Previously: returned<br/>cleanup function"| B["❌ Ignored by React<br/>No debounce effect"]
  A -->|"Now: stores timeout<br/>in useRef"| C["✅ Proper debounce<br/>Single URL update"]
  D["useEffect cleanup"] -->|"Clears timeout<br/>on unmount"| C
Loading

Grey Divider

File Changes

1. .jules/bolt.md 📝 Documentation +5/-0

Document debouncing anti-pattern learning

• Added learning note about React event handler debouncing anti-pattern
• Documented the broken implementation where cleanup functions were returned from event handlers
• Provided action guidance to use useRef for storing timeout IDs in event handlers
• Recommended cleanup in useEffect on component unmount

.jules/bolt.md


2. components/layout/TalksFilterBar.tsx 🐞 Bug fix +16/-3

Refactor search debounce using useRef pattern

• Added useRef import from React
• Created searchTimeoutRef to store timeout ID instead of local variable
• Added useEffect cleanup function to clear timeout on component unmount
• Modified handleSearchChange to clear previous timeout before setting new one
• Removed incorrect cleanup function return from event handler

components/layout/TalksFilterBar.tsx


Grey Divider

Qodo Logo

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 12, 2026

📝 Walkthrough

Walkthrough

Fixed a React debouncing anti-pattern in TalksFilterBar.tsx where cleanup logic in event handlers was ineffective. Replaced locally-created timeouts with a persistent useRef-stored timeout ID, clearing previous timers before scheduling new ones and performing unmount cleanup via useEffect. Documentation updated to reflect the issue and solution approach.

Changes

Cohort / File(s) Summary
Documentation
.jules/bolt.md
Added dated note documenting the debouncing anti-pattern in TalksFilterBar.tsx and specifying the fix: use useRef for timeout storage, clear existing timers before scheduling new ones, and cleanup on unmount.
Debouncing Implementation Fix
components/layout/TalksFilterBar.tsx
Replaced ineffective event handler cleanup pattern with persistent useRef-held timeout ID. Added unmount useEffect to clear pending timeouts. Search input changes now clear previous timeout before scheduling 300ms debounced updateFilters() call.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A timeout once lost in the handler's dark door,
Now rests in a ref, safe forevermore!
With useEffect's cleanup, on unmount we'll clear,
No dangling timeouts shall make us fear! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is partially related to the changeset. It mentions 'Fix search debounce' which directly corresponds to the core change (fixing debouncing in TalksFilterBar), but includes an emoji and emphasizes 'redundant URL updates and renders' which, while a side effect of the fix, are not the primary technical change being made.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bolt/fix-search-debounce-6944799865442708773

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 12, 2026

Code Review by Qodo

🐞 Bugs (1)   📘 Rule violations (0)   📎 Requirement gaps (0)   🎨 UX Issues (0)
🐞\ ≡ Correctness (1)

Grey Divider


Action required

1. Stale track in debounce 🐞
Description
A pending debounced search timer can call updateFilters with a stale selectedTrack if the user
clicks a track pill within the 300ms debounce window, reverting the URL (and rendered results) back
to the old track. This happens because handleTrackClick doesn’t cancel the pending search timeout
and the timeout callback closes over selectedTrack from the earlier render.
Code

components/layout/TalksFilterBar.tsx[R73-80]

+    if (searchTimeoutRef.current) {
+      clearTimeout(searchTimeoutRef.current);
+    }
+
    // Debounce the URL update for search
-    const timeoutId = setTimeout(() => {
+    searchTimeoutRef.current = setTimeout(() => {
      updateFilters(selectedTrack, newQuery);
    }, 300);
-    return () => clearTimeout(timeoutId);
Evidence
handleSearchChange schedules a timeout that calls updateFilters(selectedTrack, newQuery) using
selectedTrack from the current render, but the only timeout cancellation is inside
handleSearchChange itself (and on unmount). Since handleTrackClick immediately calls
updateFilters(newTrack, searchQuery) without clearing searchTimeoutRef.current, a previously
scheduled search timeout can still fire later and push an older track value into the URL. The talks
list is driven directly by URL params, so this late push will change what’s displayed.

components/layout/TalksFilterBar.tsx[63-81]
components/layout/TalksList.tsx[18-24]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
A debounced search timer can still be pending when the user clicks a track. When it fires, it uses a stale `selectedTrack` from closure and can overwrite the newer track choice in the URL.

### Issue Context
The timeout is cleared only on subsequent search changes and on unmount; track clicks do not clear the pending timeout.

### Fix Focus Areas
- components/layout/TalksFilterBar.tsx[63-81]

### Suggested fix
- In `handleTrackClick`, clear `searchTimeoutRef.current` (and set it back to `null`) before calling `updateFilters`.
- Optionally, avoid stale closures by storing the latest `selectedTrack` in a ref (e.g., `selectedTrackRef.current = selectedTrack` in an effect) and using that ref inside the timeout callback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes a broken debounce implementation in the TalksFilterBar component by replacing an invalid cleanup return within an event handler with a useRef and clearTimeout approach. It also adds a learning entry to the project documentation regarding this React anti-pattern. While the fix correctly addresses the immediate bug where debouncing failed entirely, the review identifies a race condition where the debounced search update can overwrite immediate track filter changes. Additionally, feedback suggests improvements to prevent the URL synchronization logic from overwriting the user's input during active typing and recommends expanding the documentation to include these race condition scenarios.

Comment on lines +78 to 80
searchTimeoutRef.current = setTimeout(() => {
updateFilters(selectedTrack, newQuery);
}, 300);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This debounced call to updateFilters captures selectedTrack from the render cycle when the user started typing. If the user clicks a track filter while the 300ms timer is running, handleTrackClick will update the URL immediately, but this timeout will eventually fire and overwrite the URL with the old track value (and the old searchParams state captured in the updateFilters closure).

Additionally, there is a significant UX issue: when updateFilters eventually updates the URL, the useEffect (at line 35) will sync the URL back to the searchQuery state. If the user is still typing, their latest characters will be overwritten by the older value from the URL (e.g., typing 'abc' rapidly might result in the input being reset to 'a' once the first debounce fires).

To fix the race condition, you should clear searchTimeoutRef.current inside handleTrackClick. To fix the input overwrite, consider using a ref to track the last pushed query and only sync from the URL if the value differs from that ref.

Comment thread .jules/bolt.md
## 2026-04-12 - React Event Handler Debouncing Anti-Pattern

**Learning:** Found a broken debounce implementation in `TalksFilterBar.tsx` where `setTimeout` was called inside an event handler (`handleSearchChange`), and a cleanup function was returned. React event handlers (unlike `useEffect`) do not consume returned cleanup functions. As a result, every keystroke queued a new timer and state update, completely failing to debounce and causing redundant renders/URL updates.
**Action:** Always use a `useRef` to store the timeout ID for debouncing inside event handlers, manually clear it before setting a new one, and clean it up in a `useEffect` on component unmount.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The action item should be expanded to mention clearing the timeout in other event handlers that perform immediate updates to the same state or URL (e.g., track selection) to prevent race conditions where a debounced update overwrites a more recent immediate update.

Comment on lines +73 to 80
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}

// Debounce the URL update for search
const timeoutId = setTimeout(() => {
searchTimeoutRef.current = setTimeout(() => {
updateFilters(selectedTrack, newQuery);
}, 300);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stale track in debounce 🐞 Bug ≡ Correctness

A pending debounced search timer can call updateFilters with a stale selectedTrack if the user
clicks a track pill within the 300ms debounce window, reverting the URL (and rendered results) back
to the old track. This happens because handleTrackClick doesn’t cancel the pending search timeout
and the timeout callback closes over selectedTrack from the earlier render.
Agent Prompt
### Issue description
A debounced search timer can still be pending when the user clicks a track. When it fires, it uses a stale `selectedTrack` from closure and can overwrite the newer track choice in the URL.

### Issue Context
The timeout is cleared only on subsequent search changes and on unmount; track clicks do not clear the pending timeout.

### Fix Focus Areas
- components/layout/TalksFilterBar.tsx[63-81]

### Suggested fix
- In `handleTrackClick`, clear `searchTimeoutRef.current` (and set it back to `null`) before calling `updateFilters`.
- Optionally, avoid stale closures by storing the latest `selectedTrack` in a ref (e.g., `selectedTrackRef.current = selectedTrack` in an effect) and using that ref inside the timeout callback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/layout/TalksFilterBar.tsx`:
- Around line 78-80: The debounced setTimeout callback closes over the
render-time selectedTrack and can overwrite a newer selection; fix by reading
the latest selectedTrack at callback time instead of relying on the closed-over
variable: add a selectedTrackRef (update it whenever selectedTrack changes or
inside the track click handler), and in the timeout callback use
selectedTrackRef.current when calling updateFilters(newQuery) (or call
updateFilters(selectedTrackRef.current, newQuery)); keep using
searchTimeoutRef.current for cancelation as before and ensure the ref is kept in
sync with the selectedTrack state in the handlers that change it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46261599-d28e-4367-a60c-ec3f5aea9d1c

📥 Commits

Reviewing files that changed from the base of the PR and between 4a848cd and 89d2220.

📒 Files selected for processing (2)
  • .jules/bolt.md
  • components/layout/TalksFilterBar.tsx

Comment on lines +78 to 80
searchTimeoutRef.current = setTimeout(() => {
updateFilters(selectedTrack, newQuery);
}, 300);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify stale-closure pattern is present in debounce callback
rg -nP --type=tsx 'setTimeout\(\(\)\s*=>\s*\{\s*updateFilters\(selectedTrack,\s*newQuery\)' components/layout/TalksFilterBar.tsx

Repository: anyulled/devbcn-nextjs

Length of output: 93


🏁 Script executed:

cat -n components/layout/TalksFilterBar.tsx | head -100

Repository: anyulled/devbcn-nextjs

Length of output: 3819


🏁 Script executed:

# Check the rest of the file to understand full context around lines 78-80
tail -n +75 components/layout/TalksFilterBar.tsx | head -20

Repository: anyulled/devbcn-nextjs

Length of output: 714


Debounced callback captures stale selectedTrack and can revert a recent track selection.

On Line 79, the timeout callback uses the render-time selectedTrack. If the user types and then clicks a track before 300ms, this callback can push stale track params and overwrite the newer selection.

💡 Proposed fix
   const [selectedTrack, setSelectedTrack] = useState<string>(searchParams.get("track") || "");
   const [searchQuery, setSearchQuery] = useState<string>(searchParams.get("q") || "");
   const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const selectedTrackRef = useRef(selectedTrack);
+
+  useEffect(() => {
+    selectedTrackRef.current = selectedTrack;
+  }, [selectedTrack]);

   const handleTrackClick = (track: string) => {
     const newTrack = track === selectedTrack ? "" : track;
+    selectedTrackRef.current = newTrack;
+    if (searchTimeoutRef.current) {
+      clearTimeout(searchTimeoutRef.current);
+    }
     setSelectedTrack(newTrack);
     updateFilters(newTrack, searchQuery);
   };

   searchTimeoutRef.current = setTimeout(() => {
-    updateFilters(selectedTrack, newQuery);
+    updateFilters(selectedTrackRef.current, newQuery);
   }, 300);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/layout/TalksFilterBar.tsx` around lines 78 - 80, The debounced
setTimeout callback closes over the render-time selectedTrack and can overwrite
a newer selection; fix by reading the latest selectedTrack at callback time
instead of relying on the closed-over variable: add a selectedTrackRef (update
it whenever selectedTrack changes or inside the track click handler), and in the
timeout callback use selectedTrackRef.current when calling
updateFilters(newQuery) (or call updateFilters(selectedTrackRef.current,
newQuery)); keep using searchTimeoutRef.current for cancelation as before and
ensure the ref is kept in sync with the selectedTrack state in the handlers that
change it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant