PettyUI/.firecrawl/stanza-compound-components.md
Mats Bosson db906fd85a Fix linting config and package fields
- Replace .eslintrc.cjs with eslint.config.mjs (ESLint 9 flat config)
  using direct eslint-plugin-solid + @typescript-eslint/parser approach
- Add @typescript-eslint/parser to root devDependencies
- Add main/module/types top-level fields to packages/core/package.json
- Add resolve.conditions to packages/core/vite.config.ts
- Create packages/core/tsconfig.test.json for test type-checking
- Remove empty paths:{} from packages/core/tsconfig.json
2026-03-29 02:35:57 +07:00

536 lines
19 KiB
Markdown

Compound components let a group of related components share implicit state through Context, giving consumers full control over layout and composition. Think of how `<select>` and `<option>` work together in HTML — neither makes sense alone, but together they form a flexible API where the browser handles state internally. This is the same idea, applied to React component libraries.
## Key Takeaways
- 1Compound components share state implicitly through React Context — child components read from the parent provider without any prop drilling
- 2The dot notation API (\`Tabs.Tab\`, \`Tabs.Panel\`) groups sub-components under the parent, making the API discoverable and signaling that these components belong together
- 3This pattern gives consumers total control over the markup: they can reorder children, wrap them in custom layouts, or conditionally render specific parts without the component library needing to anticipate every case
- 4Always throw a descriptive error when a sub-component is used outside its parent provider — silent failures waste hours of debugging time
- 5Supporting both controlled and uncontrolled modes makes compound components drop-in for simple use cases and fully integrable in complex state management scenarios
- 6Every major headless UI library (Radix, Headless UI, Ark UI) uses compound components as their primary API pattern — this is not a niche technique, it is the industry standard for component library design
## Examples
### The problem: a props-heavy component that falls apart
tsxCopy
```
// This is what compound components replace.
// Every new requirement means another prop.
type AccordionProps = {
items: {
id: string;
title: string;
content: ReactNode;
icon?: ReactNode;
disabled?: boolean;
headerClassName?: string;
contentClassName?: string;
}[];
allowMultiple?: boolean;
defaultOpen?: string[];
onChange?: (openIds: string[]) => void;
renderHeader?: (item: Item, isOpen: boolean) => ReactNode;
renderContent?: (item: Item) => ReactNode;
headerAs?: ElementType;
animated?: boolean;
variant?: 'default' | 'bordered' | 'ghost';
};
// 50 lines in, and the consumer still can't put
// a badge counter next to one specific header.
// This API does not scale.
```
Configuration-object APIs hit a wall fast. Every layout variation becomes another prop, and custom rendering requires render props bolted onto an already crowded interface. Compound components solve this by giving the consumer JSX instead of config.
### Tabs — the classic compound component
tsxCopy
```
import { createContext, useContext, useState, ReactNode } from 'react';
type TabsContext = {
activeTab: string;
setActiveTab: (id: string) => void;
};
const TabsCtx = createContext<TabsContext | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error('Tabs compound components must be rendered inside <Tabs>');
return ctx;
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsCtx.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsCtx.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist" className="tab-list">{children}</div>;
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
role="tab"
aria-selected={activeTab === id}
className={activeTab === id ? 'tab active' : 'tab'}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage — the consumer controls everything
<Tabs defaultTab="code">
<Tabs.List>
<Tabs.Tab id="code">Code</Tabs.Tab>
<Tabs.Tab id="preview">Preview</Tabs.Tab>
<Tabs.Tab id="tests">Tests <Badge count={3} /></Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="code"><CodeEditor /></Tabs.Panel>
<Tabs.Panel id="preview"><LivePreview /></Tabs.Panel>
<Tabs.Panel id="tests"><TestRunner /></Tabs.Panel>
</Tabs>
```
The parent Tabs component owns the state and provides it through Context. Each sub-component reads only what it needs. Notice how the consumer can put a Badge inside a specific Tab without the component API knowing about badges at all. That flexibility is the entire point.
### Accordion with single and multi-expand modes
tsxCopy
```
type AccordionCtx = {
openItems: Set<string>;
toggle: (id: string) => void;
};
const AccordionCtx = createContext<AccordionCtx | null>(null);
function useAccordion() {
const ctx = useContext(AccordionCtx);
if (!ctx) throw new Error('<Accordion.*> must be used within <Accordion>');
return ctx;
}
type AccordionProps = {
allowMultiple?: boolean;
defaultOpen?: string[];
children: ReactNode;
};
function Accordion({ allowMultiple = false, defaultOpen = [], children }: AccordionProps) {
const [openItems, setOpenItems] = useState(new Set(defaultOpen));
const toggle = (id: string) => {
setOpenItems(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!allowMultiple) next.clear();
next.add(id);
}
return next;
});
};
return (
<AccordionCtx.Provider value={{ openItems, toggle }}>
<div className="accordion">{children}</div>
</AccordionCtx.Provider>
);
}
function Item({ children, className }: { children: ReactNode; className?: string }) {
return <div className={`accordion-item ${className ?? ''}`}>{children}</div>;
}
function Trigger({ id, children }: { id: string; children: ReactNode }) {
const { openItems, toggle } = useAccordion();
return (
<button
aria-expanded={openItems.has(id)}
aria-controls={`panel-${id}`}
onClick={() => toggle(id)}
className="accordion-trigger"
>
{children}
<ChevronIcon rotated={openItems.has(id)} />
</button>
);
}
function Content({ id, children }: { id: string; children: ReactNode }) {
const { openItems } = useAccordion();
if (!openItems.has(id)) return null;
return <div id={`panel-${id}`} className="accordion-content">{children}</div>;
}
Accordion.Item = Item;
Accordion.Trigger = Trigger;
Accordion.Content = Content;
// FAQ page
<Accordion allowMultiple defaultOpen={['q1']}>
<Accordion.Item>
<Accordion.Trigger id="q1">How does billing work?</Accordion.Trigger>
<Accordion.Content id="q1">
<p>You are billed monthly based on your plan tier.</p>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item>
<Accordion.Trigger id="q2">Can I cancel anytime?</Accordion.Trigger>
<Accordion.Content id="q2">
<p>Yes, there are no long-term contracts.</p>
</Accordion.Content>
</Accordion.Item>
</Accordion>
```
The Accordion manages which items are open via a Set. The allowMultiple flag determines whether opening one item closes the others. Sub-components only interact with the context they need: Trigger calls toggle, Content reads openItems. This separation means you can place Trigger and Content anywhere in the tree as long as they are inside an Accordion.
### Select/Dropdown with keyboard navigation
tsxCopy
```
type SelectCtx = {
isOpen: boolean;
selectedValue: string | null;
highlightedIndex: number;
setOpen: (open: boolean) => void;
select: (value: string) => void;
setHighlightedIndex: (index: number) => void;
options: string[];
registerOption: (value: string) => void;
};
const SelectCtx = createContext<SelectCtx | null>(null);
function useSelect() {
const ctx = useContext(SelectCtx);
if (!ctx) throw new Error('Select.* must be used within <Select>');
return ctx;
}
function Select({ children, onChange }: { children: ReactNode; onChange?: (v: string) => void }) {
const [isOpen, setOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [options, setOptions] = useState<string[]>([]);
const registerOption = (value: string) => {
setOptions(prev => prev.includes(value) ? prev : [...prev, value]);
};
const select = (value: string) => {
setSelectedValue(value);
setOpen(false);
onChange?.(value);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') setHighlightedIndex(i => Math.min(i + 1, options.length - 1));
if (e.key === 'ArrowUp') setHighlightedIndex(i => Math.max(i - 1, 0));
if (e.key === 'Enter' && isOpen) select(options[highlightedIndex]);
if (e.key === 'Escape') setOpen(false);
};
return (
<SelectCtx.Provider value={{
isOpen, selectedValue, highlightedIndex,
setOpen, select, setHighlightedIndex,
options, registerOption
}}>
<div className="select" onKeyDown={handleKeyDown} tabIndex={0}>
{children}
</div>
</SelectCtx.Provider>
);
}
function Trigger({ children, placeholder }: { children?: ReactNode; placeholder?: string }) {
const { isOpen, setOpen, selectedValue } = useSelect();
return (
<button
className="select-trigger"
aria-expanded={isOpen}
onClick={() => setOpen(!isOpen)}
>
{selectedValue ?? placeholder ?? 'Select...'}
{children}
</button>
);
}
function Options({ children }: { children: ReactNode }) {
const { isOpen } = useSelect();
if (!isOpen) return null;
return <ul role="listbox" className="select-options">{children}</ul>;
}
function Option({ value, children }: { value: string; children: ReactNode }) {
const { select, selectedValue, registerOption } = useSelect();
useEffect(() => { registerOption(value); }, [value]);
return (
<li
role="option"
aria-selected={selectedValue === value}
onClick={() => select(value)}
className={selectedValue === value ? 'option selected' : 'option'}
>
{children}
</li>
);
}
Select.Trigger = Trigger;
Select.Options = Options;
Select.Option = Option;
// Usage
<Select onChange={handleLanguageChange}>
<Select.Trigger placeholder="Pick a language" />
<Select.Options>
<Select.Option value="typescript">TypeScript</Select.Option>
<Select.Option value="rust">Rust</Select.Option>
<Select.Option value="go">Go</Select.Option>
</Select.Options>
</Select>
```
A Select component demonstrates a more complex compound pattern. The parent manages open state, selection, keyboard navigation, and an option registry. Each Option registers itself on mount so the parent knows about all available options for keyboard navigation. This is a simplified version of what libraries like Radix UI and Headless UI ship.
### Modal with Header, Body, Footer slots
tsxCopy
```
import { createContext, useContext, ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
type ModalCtx = {
onClose: () => void;
};
const ModalCtx = createContext<ModalCtx | null>(null);
function useModal() {
const ctx = useContext(ModalCtx);
if (!ctx) throw new Error('Modal.* must be used within <Modal>');
return ctx;
}
type ModalProps = {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
};
function Modal({ isOpen, onClose, children }: ModalProps) {
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<ModalCtx.Provider value={{ onClose }}>
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content"
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>
</ModalCtx.Provider>,
document.body
);
}
function Header({ children }: { children: ReactNode }) {
const { onClose } = useModal();
return (
<div className="modal-header">
{children}
<button onClick={onClose} aria-label="Close">&times;</button>
</div>
);
}
function Body({ children }: { children: ReactNode }) {
return <div className="modal-body">{children}</div>;
}
function Footer({ children }: { children: ReactNode }) {
return <div className="modal-footer">{children}</div>;
}
Modal.Header = Header;
Modal.Body = Body;
Modal.Footer = Footer;
// Usage
<Modal isOpen={showConfirm} onClose={() => setShowConfirm(false)}>
<Modal.Header><h2>Delete project?</h2></Modal.Header>
<Modal.Body>
<p>This will permanently delete <strong>{project.name}</strong> and all its data.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="ghost" onClick={() => setShowConfirm(false)}>Cancel</Button>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
</Modal.Footer>
</Modal>
```
The Modal shares onClose through Context so the Header can render a close button without the consumer wiring it up. The Footer is optional — if you only need a confirmation message, skip it entirely. Portals ensure the modal escapes parent overflow and z-index stacking. This pattern shows compound components working beyond just state sharing — they also provide structural conventions.
### Controlled mode — letting parents own the state
tsxCopy
```
type TabsProps = {
// Uncontrolled: component owns state
defaultTab?: string;
// Controlled: parent owns state
activeTab?: string;
onTabChange?: (id: string) => void;
children: ReactNode;
};
function Tabs({ defaultTab, activeTab: controlledTab, onTabChange, children }: TabsProps) {
const [internalTab, setInternalTab] = useState(defaultTab ?? '');
const isControlled = controlledTab !== undefined;
const activeTab = isControlled ? controlledTab : internalTab;
const setActiveTab = (id: string) => {
if (!isControlled) setInternalTab(id);
onTabChange?.(id);
};
return (
<TabsCtx.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsCtx.Provider>
);
}
// Uncontrolled — fire and forget
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="api">API</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview">...</Tabs.Panel>
<Tabs.Panel id="api">...</Tabs.Panel>
</Tabs>
// Controlled — parent syncs with URL params
const [tab, setTab] = useState(searchParams.get('tab') ?? 'overview');
<Tabs activeTab={tab} onTabChange={(id) => {
setTab(id);
setSearchParams({ tab: id });
}}>
{/* same children */}
</Tabs>
```
Supporting both modes is essential for real-world compound components. Uncontrolled mode is convenient for static UIs. Controlled mode is necessary when the active state needs to sync with URL parameters, form state, or other components. The pattern checks whether a controlled value is provided and delegates accordingly.
## Common Mistakes
Mistake:
Using \`React.Children.map\` and \`cloneElement\` to inject props into children instead of using Context
Fix:
Context works regardless of nesting depth and does not break when consumers wrap sub-components in divs, fragments, or custom wrappers. cloneElement only works on direct children and silently fails when the tree structure changes. Context is the correct primitive for compound components.
Mistake:
Failing to throw an error when a sub-component is used outside its parent provider — returning null or using a default value instead
Fix:
Always throw a descriptive error in the custom hook: \`throw new Error('Tabs.Tab must be used within <Tabs>')\`. Silent failures lead to hours of debugging. The developer needs to know immediately that they placed a component in the wrong part of the tree.
Mistake:
Putting too much into the Context value — exposing internal state, dispatch functions, and derived values that sub-components never use
Fix:
Keep the context value minimal. If some sub-components need data that others do not, consider splitting into two contexts (one for state, one for dispatch) or providing only what the public sub-components actually read. A bloated context causes unnecessary re-renders across all consumers.
Mistake:
Creating a new context value object on every render, which triggers re-renders in all consumers even when the actual state has not changed
Fix:
Memoize the context value with \`useMemo\`: \`const value = useMemo(() => ({ activeTab, setActiveTab }), \[activeTab\])\`. This ensures consumers only re-render when the state they depend on actually changes.
## Best Practices
- Use the dot notation pattern (\`Tabs.Tab\`, \`Tabs.Panel\`) to attach sub-components to the parent. This makes the API self-documenting — autocomplete shows all available sub-components when you type \`Tabs.\`
- Support both controlled and uncontrolled modes. Accept \`defaultValue\` for uncontrolled usage and \`value\` + \`onChange\` for controlled usage. Check if the controlled prop is defined to determine which mode to use.
- Memoize the Context value with \`useMemo\` to prevent unnecessary re-renders. This matters especially when the parent component has props or state unrelated to the compound component's shared state.
- Write a custom hook (e.g., \`useTabs\`, \`useAccordion\`) that throws on null context instead of using \`useContext\` directly in sub-components. This centralizes error handling and gives you a single place to add logging or validation.
- Keep sub-components focused on one responsibility. The Trigger handles click events, the Content handles visibility, the Item provides grouping. Do not merge responsibilities — it defeats the composability that makes this pattern valuable.
## Summary
Compound components are a group of related React components that share implicit state through Context, giving consumers full control over markup and layout. The parent component provides state via a Context Provider, and sub-components consume it through a custom hook. This pattern replaces sprawling props-based APIs with a composable, declarative interface. Attach sub-components using dot notation for discoverability, throw clear errors when components are used outside their parent, memoize the context value, and support both controlled and uncontrolled modes. Every serious React component library — Radix, Headless UI, Ark UI — builds on this pattern.
## Go deeper with Stanza
Practice react compound components with interactive lessons and challenges.
[React Components & Patterns](https://www.stanza.dev/courses/react-components-patterns) [React 19 & Patterns](https://www.stanza.dev/courses/react-modern-patterns)
## Related Concepts
[React Context Api](https://www.stanza.dev/concepts/react-context-api "React Context Api") [React Custom Hooks](https://www.stanza.dev/concepts/react-custom-hooks "React Custom Hooks") [React Usestate](https://www.stanza.dev/concepts/react-usestate "React Usestate") [React Error Boundaries](https://www.stanza.dev/concepts/react-error-boundaries "React Error Boundaries")
## Related Cheatsheets
[React Hooks Cheatsheet](https://www.stanza.dev/cheatsheet/react-hooks "React Hooks Cheatsheet")