self-contained-modals
UsefulSoftwareCo/executor/.claude/skills/self-contained-modals/SKILL.md
Build modals/dialogs self-contained: form and in-flight state lives inside, closing unmounts it. Use when writing or reviewing a modal/dialog, especially one that owns async work (OAuth popups, timers, subscriptions, AbortControllers). Catches the stuck-on-Connecting class of lifecycle-leak bugs.
Skill4.1k starsChanged 5 months ago
What's in it
- Self-contained modals
- Why
- How to apply
- Reviewing: flag these smells
---
name: self-contained-modals
description: "Build modals/dialogs self-contained: form and in-flight state lives inside, closing unmounts it. Use when writing or reviewing a modal/dialog, especially one that owns async work (OAuth popups, timers, subscriptions, AbortControllers). Catches the stuck-on-Connecting class of lifecycle-leak bugs."
---
# Self-contained modals
A modal's **form state and in-flight work live INSIDE the modal**, and **closing
the modal UNMOUNTS that state** so it is destroyed, not hand-reset. Only the
**open/route intent** belongs to the parent (deep links, programmatic open,
reconnect handoffs need it).
## Why
A hand-written `reset()` has to enumerate every field, and it silently drifts out
of sync with state owned by **child hooks the parent can't see**. Unmounting
resets everything for free, including child-hook cleanup effects (cancelling a
dangling server session, clearing a timer, aborting a fetch).
Concrete bug this prevents (executor, add-account-modal): the modal was mounted
unconditionally, so `useOAuthPopupFlow`'s `busy` survived close. `reset()` zeroed
its own booleans but never called `oauthPopup.cancel()`. Abandon the OAuth popup,
close, reopen, and `oauthBusy = false || busy(true) = true`, so the footer is
wedged on "Connecting…" with Close disabled. Unmounting would have cleared `busy`
AND run the hook's cleanup that cancels the server OAuth session.
## How to apply
Default to **genuine conditional unmount**, state inside. When closed the
component returns `null`, so React destroys all of it and runs every child
hook's cleanup. This is the cleanest fix and the one to reach for first:
```tsx
function Parent() {
const [open, setOpen] = useState(false);
return open ? <Modal onClose={() => setOpen(false)} /> : null;
}
```
A key bump (`<Body key={openCount} />`) is **still a manual reset**, just
spelled as a remount. Prefer real unmount; only reach for keyed remount in the
one case below.
That case: **Radix Dialog** (this repo's `components/dialog.tsx`) Content/Overlay
use `data-[state=closed]:animate-out` exit animations, so unmounting the whole
`Dialog` drops the close animation. If you must keep that animation, keep the
`Dialog` + `DialogContent` shell mounted and remount only the state-bearing
**body** per open:
```tsx
function Parent() {
const [open, setOpen] = useState(false);
const [openCount, setOpenCount] = useState(0);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>{open ? <ModalBody key={openCount} /> : null}</DialogContent>
</Dialog>
);
}
```
This only works when `DialogContent` is **independent of body state**. If the
shell depends on the body (e.g. a width className driven by the body's current
sub-view), the body must own `DialogContent`, so there is no stable shell to
keep, and genuine unmount (losing the exit animation) is the right call. That is
exactly the executor add-account-modal: it genuinely unmounts and accepts the
lost animation rather than plumbing body state up to a shell.
## Reviewing: flag these smells
1. A hand-written `reset()` exists. Its presence means state outlives the modal;
ask why the modal isn't just unmounted.
2. An always-mounted dialog (rendered unconditionally with an `open` prop) that
owns async/in-flight state: popups, timers, subscriptions, AbortControllers,
server sessions.
3. A busy/loading flag composed from a child hook (e.g. `ccBusy || someHook.busy`)
where `reset()` clears only part of it.
More agent context in UsefulSoftwareCo/executor
17 other files this repository gives its agents.
AGENTS.md
CLAUDE.md
Skill
- stack.agents/skills/stack/SKILL.md
- wrdn-effect-atom-optimistic.agents/skills/wrdn-effect-atom-optimistic/SKILL.md
- wrdn-effect-atom-reactivity-keys.agents/skills/wrdn-effect-atom-reactivity-keys/SKILL.md
- wrdn-effect-promise-exit.agents/skills/wrdn-effect-promise-exit/SKILL.md
- wrdn-effect-raw-fetch-boundary.agents/skills/wrdn-effect-raw-fetch-boundary/SKILL.md
- wrdn-effect-schema-boundaries.agents/skills/wrdn-effect-schema-boundaries/SKILL.md
- wrdn-effect-schema-inferred-types.agents/skills/wrdn-effect-schema-inferred-types/SKILL.md
- wrdn-effect-typed-errors.agents/skills/wrdn-effect-typed-errors/SKILL.md
- wrdn-effect-value-inferred-types.agents/skills/wrdn-effect-value-inferred-types/SKILL.md
- wrdn-effect-vitest-tests.agents/skills/wrdn-effect-vitest-tests/SKILL.md
- wrdn-package-boundaries.agents/skills/wrdn-package-boundaries/SKILL.md
- wrdn-typescript-type-safety.agents/skills/wrdn-typescript-type-safety/SKILL.md
- emulate.claude/skills/emulate/SKILL.md
- prod-telemetry.claude/skills/prod-telemetry/SKILL.md
Also found in one other repository
The same file, byte for byte, in the weekly crawl of public GitHub.
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

