How multiple sources of truth bite you(local vs URL state)
Two systems were owning the same value.
One of them was bluffing.
- ·Why useState + router.push is two controllers, not one.
- ·How to spot the bug before you ship it. And after.
- ·The cleanest fix is almost always deletion.
The bug
You click the first tab. It works.
You click the second tab. Nothing.
You click it again. Still nothing. You open devtools. The handler fires. State updates. The URL is what you expected.
And the tab won't move.
I've spent 45 minutes on this bug. Maybe you have too. The tab component is fine. The handler is fine. The URL is fine.
The mental model is broken.
The setup
Picture the Activity tab in a Coinbase-style dashboard. Four tabs:
- All Activity
- Trades
- Deposits & Withdrawals
- Transfers
Users want to share a filtered link. They want the back button to work. So you sync the active tab to the URL. You write what everyone writes:
const [activeTab, setActiveTab] = useState('all');
function handleTabClick(tab) {
setActiveTab(tab);
router.push(`?tab=${tab}`);
}Local state for snappy UI. URL for persistence. Sensible.
Also wrong.
You just gave two systems permission to own the same value. They will keep agreeing until the moment they don't. That moment is your second click.
Watch it break
Click the first tab. Both stores update. UI moves. Feels right.
Click another tab. setActiveTab fires. The local value updates. The render reads from the URL. The URL is already locked in.
The click goes nowhere.
Click around. First click moves. The rest go quiet. Watch localTab and urlTab drift apart in the readout. That drift is the bug.
The real problem
It isn't a tab problem. It's a sources-of-truth problem.
One value. Two owners. The first click worked because they happened to agree. The second click exposed the lie.
Sync is a smell. Every time you write code to keep two stores in step, you're paying for a decision you should have made differently up front.
The fix
Delete the local state.
const searchParams = useSearchParams();
const activeTab = searchParams.get('tab') ?? 'all';
function handleTabClick(tab) {
router.push(`?tab=${tab}`);
}No useState. No sync code. Nothing to get out of step.
The URL is just a prop the whole app can read. Click a tab, push to the URL, React re-renders. The active tab and the URL are the same thing now. They can't disagree because there's only one of them.
What deletion bought you
You didn't lose anything. You removed a controller that wasn't in charge.
The URL was already the right place for this state.
- Shared links work.
- Bookmarks work.
- Back and forward work.
- A teammate pasting a URL in Slack works.
You didn't implement any of that. You just stopped fighting it.
Reaching for useState here was like buying a second clock so you'd never be wrong about the time. Now you're never sure which one to trust.The law
One value. One owner.
When you see two stores trying to control the same piece of state, the answer is almost never “sync them better.” It's “delete one.”
Every piece of state you own is a liability. Most state bugs are quiet. They don't throw, they don't crash, they just stop matching reality. The cheapest way to ship a system that doesn't do that is to own less.
Takeaways
useState+router.pushfor the same value is two controllers, not one.- The bug shows up on the second click. And on back. And on shared links.
- Read directly from the URL. The desync class of bug disappears.
- Often times the best way is simpler, not complex. Once you know the principles, you can build systems that scale.
10+ modules. Mental models, applied lessons, virtualization, React 19, Next.js. $100 early-bird, one-time fee.