Skip to content
Twenty One Media
webSeptember 16, 2026

The Mobile Menu That Closes on Navigation for Free

When you open a mobile menu and navigate to another page, the menu should close. The obvious implementation reaches for useEffect:

const [open, setOpen] = useState(false);
useEffect(() => {
  setOpen(false);
}, [pathname]);

That works, but it earns a lint warning. The effect depends on pathname as a trigger, not as a value it actually reads. ESLint flags it, and if the deps list drifts you get stale-closure bugs. Our old header had this pattern, and we left a note in HANDOFF.md about the warning rather than fixing the underlying problem.

The redesigned header stores the pathname at open-time instead:

const [menuPath, setMenuPath] = useState<string | null>(null);
const open = menuPath === pathname;

Toggle logic on the button:

onClick={() => setMenuPath(open ? null : pathname)}

When you open the menu, menuPath gets the current pathname. open is derived as true because menuPath === pathname. When navigation happens, pathname changes and menuPath is still the old value, so open is false. The menu unmounts. No effect needed.

This isn't just a cleaner pattern, it's more correct. The useEffect version closes the menu after the render: React paints the new page, then the effect fires, then React renders again with the menu gone. There's an intermediate frame where the menu is visible on the wrong page. With derived state, open is already false the moment React processes the new pathname. The intermediate frame doesn't exist.

We still call setMenuPath(null) explicitly on link clicks so the menu closes the instant the user taps, before the navigation resolves. And we keep one useEffect for the Escape key listener and the close-on-desktop-resize behavior, both of which genuinely react to events rather than state comparisons. But the pathname cleanup is gone entirely.

One more benefit: browser back and forward work correctly without extra handling. Popping the history stack changes pathname, so open goes false on the same render as the page change. The useEffect approach would also handle this, but again, one frame late.

The lint error in the old header was pointing at a symptom. The real problem was storing a boolean when the question being answered is "was this menu opened on the current page." Store the answer to that question, derive the boolean from it, and the cleanup writes itself.