DEV Community

Cover image for The Code I Couldn't Leave Alone
Shubhra Pokhariya
Shubhra Pokhariya

Posted on Originally published at shubhra.dev AI-assisted

The Code I Couldn't Leave Alone

The slippery slope of a quick bug fix

The bug report was simple enough. On the reports dashboard, if you changed the date range while a filter was already active, the page number didn't reset. So a user could end up on page 4 of a report that now only had one page of results, staring at an empty table with no idea why.

I found it fast. The pagination state and the filter state were living in two different places, useState in the table component and useSearchParams for the filters, and nothing was watching one to reset the other. I added a useEffect that reset the page to 1 whenever the search params changed. It was the smallest change that unbroke the dashboard, and I wasn't about to restructure the component tree for a twenty minute fix. Tested it locally, clicked through a few filter combinations, watched the empty table problem disappear. Twenty minutes, maybe less.

I didn't open the pull request yet. I told myself I wanted to read through the diff once more before pushing.

That's when I actually looked at ReportFilters.tsx for the first time in a while, and I didn't love what I saw. The filter state existed in two places at once. Some of it lived in the URL through useSearchParams, which is what actually drove the API call, and some of it lived in local component state that only existed to control what the dropdowns displayed. Most of the time they matched. Occasionally, if you clicked fast enough, they didn't, and you'd see a dropdown showing one filter while the table quietly fetched with another. Nobody had filed a bug for that. I don't think anyone had ever managed to click fast enough to notice.

But I'd noticed it now, and once you've seen a bug like that, it's hard to unsee it. It reminded me of a race condition I once tracked down in a checkbox that looked right on screen while the requests behind it quietly disagreed, the same kind of mismatch between what the UI shows and what actually happened. Different bug, same shape.

So I pulled the local state out entirely and made the URL params the single source of truth, reading everything through useSearchParams and writing through router.replace. That's a real fix. Two sources of truth for the same data is exactly the kind of thing that eventually causes a much worse bug than the one I'd started with. I felt good about that change, and I still do.

While I was in there, I noticed the filter logic itself, the actual function that turned the search params into a query object, was duplicated. It existed once in the component to build the fetch request, and again inside app/api/reports/route.ts to validate incoming params on the server. Same logic, written twice, slightly out of sync, because someone had updated one copy when a new filter type was added and forgotten the other. I pulled it into a shared lib/filters.ts and imported it in both places. That's not a nice to have. That's the kind of drift that can quietly turn into a production bug.

At this point I'd fixed the original bug, removed a genuine duplicate source of truth, and closed a real gap between client and server validation. If I'd stopped there, this would just be a normal Tuesday. I didn't stop there.

Once the filter logic was centralized, I looked at the route handler again and thought, well, since I'm already reading through this, the report data itself doesn't actually need to be client fetched at all. It's not interactive in any way that requires it. It could be a server component, fetched directly with the filters coming from searchParams in the page props, no client-side fetch, no loading spinner, no waterfall. In my head it looked roughly like this.

export default async function ReportsPage({
  searchParams,
}: {
  searchParams: Promise<{ range?: string; status?: string }>;
}) {
  const { range, status } = await searchParams;
  const reports = await getReports({ range, status });
  return <ReportsTable data={reports} />;
}
Enter fullscreen mode Exit fullscreen mode

That's a bigger change than a bug fix. It touches the page structure, it means rethinking how the table gets its data, it means the loading state disappears entirely because the server just renders with the data already there.

I started doing it anyway.

I got about halfway through converting the table to a server component before I stopped, not because it was going badly, but because I glanced at the git diff panel in my editor and the file count had gone from one changed file to seven. Seven files, for a bug that was, structurally, a missing line inside a useEffect.

I sat there for a second looking at that number, and the question that actually stopped me wasn't "is this too much work." It was smaller and quieter than that. I asked myself whether the server component conversion was something the product needed right now, or whether it was something I wanted to do because I could see exactly how to do it and it bothered me to leave it undone once I'd seen it.

I didn't have a clean answer immediately, which is honestly what made me pay attention. The filter state fix and the duplicated logic fix, I could justify those in one sentence each, to myself or to a reviewer. The server component rewrite took a full paragraph to justify, and most of that paragraph was about how satisfying the final version would be, not about what was currently broken for anyone using the dashboard.

That's usually the tell, I think. Not the size of the change. Plenty of small changes are indulgent and plenty of large ones are necessary. It's how long the justification takes, and who the justification is actually for.

So I split it. I finished the bug fix and the shared filter logic, wrote a focused pull request that a reviewer could read in five minutes and understand completely, merged it the same day. The server component rewrite went into its own branch with its own description, explaining what it would remove, what it would simplify, and why it was worth doing on its own terms instead of riding in quietly behind a one-line bug fix. It's a good change. I still believe that. It just needed to stand on its own, reviewed and evaluated as what it actually was, instead of hiding inside something smaller.

What stays with me isn't that I almost shipped too much. It's how reasonable every single step felt while I was inside it. Nobody would have blinked at any individual commit. The dropdown desync fix, obviously worth doing. The shared filter util, obviously worth doing. Even the server component conversion, on its own merits, was obviously worth doing. Stacked together in one sitting, driven by one bug report, they stopped being a response to a problem and became a response to my own discomfort with leaving the file the way I found it.

I don't think that discomfort is something to train out of myself. It's most of why I'm good at this. Being able to see the second problem while you're fixing the first one is a real skill, and I'd rather have it than not. But seeing a change and shipping a change used to be the same motion for me, and somewhere in that two hour stretch I finally felt the gap between them instead of just hearing about it in theory.

I closed the laptop that evening having shipped exactly one thing, a twenty minute bug fix with a real structural improvement folded honestly into it. The bigger rewrite is sitting in review on its own branch, where it belongs, where someone can actually look at it and ask whether it's worth the seven files, instead of me deciding that alone at ten at night because I couldn't stand leaving a route handler half server, half client, one more day.

Top comments (14)

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper •

Nice debugging! It often happens that a bug fix I imagine as a small part of one file ends up being a large fix across many files. πŸ˜…

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you! 😊 That's the tricky part. Sometimes those extra changes are genuinely worth doing, but they don't always belong in the same fix. I've been learning to separate the two.

Collapse
 
leonore_fcf3095de32ca8433 profile image
Leonore • • Edited

This is a great reminder that good refactoring is not always the same as doing every improvement you can see. I especially liked the distinction between fixing a real structural problem and using a bug fix as an excuse to redesign the whole feature. Keeping the larger rewrite in its own PR makes the reasoning and review much clearer.
For developers working through similar refactoring decisions, CodeArea.net can also be a useful resource for practical development workflows.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks, Leonore! Really appreciate you taking the time to read the post and share your thoughts. Keeping a fix focused instead of turning it into a larger rewrite is something I pay much more attention to now. And thanks for sharing CodeArea.net as well.

Collapse
 
shayan-araghi profile image
Shayan Araghi •

Hi Shubhra, great post! As engineers, we always want to optimize and fix problems. Your post is a great reminder that it's better to slow down and fix what is needed before trying to make a bigger optimization.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you, Shayan! That instinct is worth keeping, so I'm not trying to train it out of myself. What helped me was taking a moment to ask how long it would take to justify a change before shipping it. The two smaller fixes took a sentence each, but the server component rewrite took a paragraph, so I moved that one into its own branch. 😊

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala •

Shubhra, the β€œseven files for a bug that was missing one line” πŸ˜„ I think a lot of us have been there. It’s so easy to keep going once you notice other things, even when the original problem was already fixed.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Hema, exactly! 😊 The hardest part wasn't finding the original bug. It was knowing when to stop fixing things I noticed along the way. I've definitely learned that not every good improvement needs to go into the same PR.

Collapse
 
mudassirworks profile image
Mudassir Khan •

The taxonomy you're building here is the useful one. Not 'scope creep happened' but 'which expansion was legitimate'.

Fixing the pagination reset, merging to a single source of truth for filter state, deduplicating the filter logic. Those are all load bearing changes. Each one closes a real failure mode. The line you draw is exactly right: the server component refactor is a different class of thing, because none of the existing bugs required it. It's an optimization dressed as a fix.

The hardest call in a diff like this is often not 'should I do this' but 'should I do this in this PR'. Which of those three expansions would you have split into a separate ticket if the codebase had a formal review process?

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you, Mudassir! Honestly, the pagination reset and the single source of truth for filter state wouldn't have been separate tickets for me, even with a formal review process. They were closely connected to the filter and pagination problems I was already investigating.

The duplicated filter logic is the one I'd have thought about splitting. It fixed a genuine client/server validation gap, so I can justify keeping it in the same PR. But it was a separate issue from the original pagination bug, and in a formal review process, I'd at least consider giving it its own ticket.

The server component rewrite was the clear one for me. It wasn't needed to fix any of the existing bugs, and the justification was increasingly about improving the code rather than addressing the reported problem. That's where I'd draw the line between a useful refactor and a change that deserves its own PR.

Collapse
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Nice write-up shubhra! ❀️

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you so much, Divya! ❀️ Glad you enjoyed it!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.