react-collapsible/Concepts/Controlled state
Concepts~4 min

Controlled state

A Collapsible never opens or closes itself. You pass open; it animates to match. That one prop is the entire state contract — no internal toggle, no onOpenChange, no ref to imperatively poke. Whatever owns the boolean owns the panel.

One boolean, owned by you

The canonical setup is a useState next to the trigger. The trigger flips the boolean and carries the accessibility attributes; the Collapsible renders whatever the boolean currently says.

TSXdetails.tsx
import { useState } from 'react'
import { Collapsible } from '@westopp/react-collapsible'

const Details = () => {
const [open, setOpen] = useState(false)

return (
  <>
    <button
      aria-controls="controlled-state-details"
      aria-expanded={open}
      onClick={() => setOpen((o) => !o)}
    >
      {open ? 'Hide details' : 'Show details'}
    </button>
    <Collapsible id="controlled-state-details" open={open}>
      <p>Shipped in 2 to 4 business days. Free returns within 30 days.</p>
    </Collapsible>
  </>
)
}

Because open is an ordinary prop, nothing ties it to local state. Any boolean expression works — which is exactly what patterns like a single-open accordion rely on.

TSXopen-sources.tsx
// open is a plain boolean — derive it from wherever the state lives.

// Lifted state: a single-open accordion
open={activeSection === 'shipping'}

// An external store
open={useSidebarStore((s) => s.filtersOpen)}

// URL state
open={searchParams.get('panel') === 'filters'}

Single source of truth in motion: one useState drives the button label, an indicator that lives entirely outside the Collapsible, and the panel itself.

open = false

The button label, the indicator dot, and this panel all read the same useState boolean. None of them can drift out of sync, because there is exactly one place the state lives.

Starting open with initialOpen

Some panels should be expanded on first paint — a restored sidebar, a default-open section, a form re-rendered with saved UI state. Initialising your state to true gets the panel open, but it would animate open on mount, which reads as flicker.

initialOpen fixes the mount, nothing more. For the first frame the component renders with an inline transitionDuration of 0s, so the panel appears already expanded; one animation frame later the configured duration (or the 500ms default) is restored and every later toggle animates normally.

TSXsaved-filters.tsx
// Starts expanded with no entry animation. open still rules after mount.
const [open, setOpen] = useState(true)

<Collapsible id="controlled-state-saved-filters" open={open} initialOpen>
<FilterList />
</Collapsible>

The deferred open attribute

When open flips to true, the inner content wrapper gains a bare open attribute — but one animation frame later, via requestAnimationFrame. The deferral is deliberate: the browser needs to register the element's pre-transition styles before anything that should transition changes, otherwise it snaps. Closing removes the attribute immediately.

That makes [open] a styling hook that is guaranteed to flip in sync with the collapse animation:

CSSpanel.css
/* Applies one frame after open flips true, in step with the expansion */
#controlled-state-details [open] {
border-color: var(--brand);
transition: border-color 500ms;
}

Conditional classes — the open: and closed: prefixes in className — flip on the same deferred tick. Pair them with your own transition-* utilities and they animate alongside the panel instead of jumping ahead of it.

Collapse is not unmount

A collapsed Collapsible hides its children; it never unmounts them. In both hide modes the React tree inside stays alive, so component state survives a collapse — counters keep counting, inputs keep their values, effects don't re-run on reopen.

Bump the count, collapse, reopen — it survives.

This panel also uses initialOpen — it was expanded on page load with no entry animation.

The flip side: the Collapsible has to stay mounted to do its job. If you conditionally render the component itself, closing removes it from the DOM in the same render — there is nothing left to animate, so the exit transition is lost.

TSXkeep-it-mounted.tsx
// Don't: the whole component vanishes the moment open flips false.
{open && (
<Collapsible id="controlled-state-keep-mounted" open={open}>
  <Panel />
</Collapsible>
)}

// Do: keep it mounted and let open drive visibility.
<Collapsible id="controlled-state-keep-mounted" open={open}>
<Panel />
</Collapsible>