Listbox becomes non-interactive after soft navigation when Next.js `cacheComponents` is enabled
What package within Headless UI are you using?
@headlessui/react
What version of that package are you using?
v2.2.10
What browser are you using?
Chrome (not browser-specific)
Reproduction URL
https://github.com/mordechaim/headless-listbox-navigation
The reproduction is a fresh create-next-app on Next.js 16 (16.2.9) + React 19.2.7 + @headlessui/[email protected]. The only non-default config needed is cacheComponents: true in next.config.ts.
Steps:
- On
/, open the Listbox and select an option →onChangefires (works). - Soft-navigate
/ → /a → /(client<Link>navigation, no reload). - Open the Listbox again and click an option.
With cacheComponents: true the Listbox is now dead; remove that flag (default config) and everything works. Menu is unaffected either way.
Describe your issue
When cacheComponents is enabled, after a soft client navigation away from and back to a route, every Listbox on that route renders and opens normally but is functionally dead: clicking an option doesn't select or close it, onChange never fires, and outside-click no longer dismisses it. Only re-clicking the button closes it. Menu is not affected.
Next.js uses <Activity> behind the scenes, whih is responsible for this.
Root cause (traced through the 2.2.10 source):
- With
cacheComponents, returning to a route preserves the existingListboxcomponent instance instead of remounting it, souseListboxMachine'suseMemo(() => ListboxMachine.new(), [])returns the same machine (withoutcacheComponentsthe route remounts and a fresh machine is created — hence no bug). - While the route is cached/hidden, React runs the effect cleanup for
useOnUnmount, whose deferred microtask fires and callsmachine.dispose().dispose()runsdisposables.dispose(), tearing down every handler registered in the machine constructor viathis.on(...). - One of those is the
SelectOptionhandler, which is what callsthis.actions.onChange(value)and closes the listbox. After disposal it's gone, and on return the component reuses the disposed machine (useMemodeps are[], so it's never recreated). - Clicking an option still calls
machine.actions.selectOption(value)→send(SelectOption); the reducer updates internal state, but the constructor-registered side effect (onChange+ close) never runs. Open/close still works because that's a pure reducer transition with no torn-down side effect.
Why Menu is immune: menu item activation runs in the component layer, not via a constructor-registered machine on(...) side effect, so a disposed-and-reused Menu machine still works.
Instrumented confirmation (broken state, after nav-back): the option's onClick fires with disabled=false and calls selectOption, but the machine's onChange action never runs.
Suggested fix
Recreate the machine when it has been disposed, instead of reusing it, in the machine glue — roughly: keep the machine in a ref and create a new one whenever the current is null or disposed (with dispose() setting a disposed flag), still disposing on unmount. This restores a fully-wired machine on return with no leak; the same applies to the combobox/menu/popover machine glues.
Workaround
Use a key to force unmounting:
const [key, setKey] = useState(() => Math.random())
useEffect(() => () => setKey(Math.random()), [])
return <Listbox key={key}>
</Listbox>Source: tailwindlabs/headlessui