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/react@2.2.10. The only non-default config needed is cacheComponents: true in next.config.ts.
Steps:
- On
/, open the Listbox and select an option → onChange fires (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 existing Listbox component instance instead of remounting it, so useListboxMachine's useMemo(() => ListboxMachine.new(), []) returns the same machine (without cacheComponents the 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 calls machine.dispose(). dispose() runs disposables.dispose(), tearing down every handler registered in the machine constructor via this.on(...).
- One of those is the
SelectOption handler, which is what calls this.actions.onChange(value) and closes the listbox. After disposal it's gone, and on return the component reuses the disposed machine (useMemo deps 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>
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-appon Next.js 16 (16.2.9) + React 19.2.7 +@headlessui/react@2.2.10. The only non-default config needed iscacheComponents: trueinnext.config.ts.Steps:
/, open the Listbox and select an option →onChangefires (works)./ → /a → /(client<Link>navigation, no reload).With
cacheComponents: truethe Listbox is now dead; remove that flag (default config) and everything works.Menuis unaffected either way.Describe your issue
When
cacheComponentsis enabled, after a soft client navigation away from and back to a route, everyListboxon that route renders and opens normally but is functionally dead: clicking an option doesn't select or close it,onChangenever fires, and outside-click no longer dismisses it. Only re-clicking the button closes it.Menuis not affected.Next.js uses
<Activity>behind the scenes, whih is responsible for this.Root cause (traced through the 2.2.10 source):
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).useOnUnmount, whose deferred microtask fires and callsmachine.dispose().dispose()runsdisposables.dispose(), tearing down every handler registered in the machine constructor viathis.on(...).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).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
Menuis immune: menu item activation runs in the component layer, not via a constructor-registered machineon(...)side effect, so a disposed-and-reusedMenumachine still works.Instrumented confirmation (broken state, after nav-back): the option's
onClickfires withdisabled=falseand callsselectOption, but the machine'sonChangeaction 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
nullordisposed(withdispose()setting adisposedflag), 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
keyto force unmounting: