The Leak
Open the Button in any frontend codebase older than two years. Read it.
export function Button({ onClick, children, ...props }: ButtonProps) { const flags = useFeatureFlags() const { trackEvent } = useAnalytics() const { breadcrumb } = useObservability() const { audit } = useAuditTrail() const permissions = usePermissions()
const handleClick = (e: MouseEvent) => { if (!permissions.can(props.permission)) return trackEvent('button_clicked', { id: props.id, location: props.location }) breadcrumb({ category: 'ui', message: `clicked ${props.id}` }) audit({ kind: 'click', target: props.id }) if (flags.experimentalDoubleConfirm && props.dangerous) { // ... } onClick?.(e) }
return <button onClick={handleClick} {...props}>{children}</button>}It’s a button. It now imports five product systems. Each one adds a hook. Each hook adds a context provider somewhere up the tree. Each context provider adds setup. Each setup branches based on environment, user, or A/B test. The button file is now 200 lines and the only reason it exists is to render <button>.
This is the leak. It happens slowly, one PR at a time, and it always has a sensible justification:
- We need every click in analytics — let’s just put it in the button.
- Sentry should breadcrumb interactions — let’s just put it in the button.
- We should audit every action a user takes — let’s just put it in the button.
- The new feature flag gates the button — let’s just put it in the button.
- Permissions are part of the button’s job — let’s just put it in the button.
Each addition makes sense in isolation. Together they make the button radioactive.
Why this is structurally bad, not stylistically bad
Section titled “Why this is structurally bad, not stylistically bad”The leak doesn’t just make the file long. It does five things that compound:
It couples UI to product systems. Every time you change analytics vendors, every button changes. Every time the audit format changes, every button changes. The file that should turn Save into a press-able rectangle is now a fan-in for unrelated concerns.
It defeats reuse. The button can’t be lifted into a shared package without dragging analytics, audit, flags, and permissions with it. You build a “Button” and a “PlainButton” and a “PrimitiveButton”. Pick one, every team has done it.
It corrodes accessibility. Layers of wrapping divs and event interception accumulate. Native focus order breaks. Keyboard activation gets swallowed. The component that should have been <button> is now a <div role="button" tabIndex={0} onKeyDown={...}>.
It punishes new developers. The button is the smallest component in the codebase. If understanding the button requires understanding analytics, audit, flags, observability, and permissions, the onboarding curve is vertical.
It hides architectural decay. When everything imports everything, you can’t see the dependency graph anymore. Removing a system means deleting it from a hundred files. So nothing ever gets removed.
The thing the leak is missing
Section titled “The thing the leak is missing”The reason this happens is structural, not cultural. There is no place else for these capabilities to live in a typical frontend stack. Components exist. Hooks exist. There’s no first-class concept for “a capability that observes events but isn’t owned by any component.”
So they end up in components, because components are where the click is.
Kitsune’s claim is that this concept should exist. It calls them modules. A module is a unit of capability — analytics, audit, observability, storage, notifications — installable into the runtime, not into a component. A button doesn’t import a module. A boundary doesn’t import a module. Modules attach to the runtime and observe what flows through it.
The button stays a button:
<kit-button meta-event="checkout.started" meta-intent="primary-action"> Start checkout</kit-button>Five lines. Zero imports. The five product systems still exist — they just live somewhere else now, and the button doesn’t need to know where.