PettyUI/.firecrawl/vxd-compound-component.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

226 lines
7.5 KiB
Markdown

Chris created a Dropdown menu as a shared component for the team.
At first, it was simple. It just needed to show a list when a button was pressed.
typescript
```typescript
<Dropdown items={['A', 'B']} />
```
But then, requirements started pouring in.
"Please add icons inside the menu."
"Add a search bar, too."
"Move the button position to the right."
Chris kept adding Props like showSearch, iconPosition, and customButton, eventually creating a monster component with over 20 props.
typescript
```typescript
// ❌ Prop Explosion
<Dropdown
items={items}
showSearch={true}
searchPlaceholder="Search..."
icon="arrow"
position="bottom-right"
onSelect={handleSelect}
buttonColor="blue"
// ... It never ends
/>
```
Such "Configuration-based" components are rigid.
Today, we will learn how to secure true reusability through the Compound Component Pattern, which involves assembling parts like the HTML <select> tag, and the Headless UI concept, which completely separates style from logic.
## 1\. Wisdom from HTML
Let's look at the HTML <select> tag.
typescript
```typescript
<select>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
```
We don't write <select options={\['1', '2'\]} />.
Instead, two components, <select> and <option>, work together. <select> manages the state (selected value), and <option> handles the UI. This is the prototype of the Compound Component Pattern.
## 2\. Implementing Compound Components
To implement this pattern in React, we need the Context API. This is because the parent component needs to share state implicitly with its children.
### Step 1: Create Context
typescript
```typescript
// SelectContext.tsx
import { createContext, useContext, useState } from 'react';
type SelectContextType = {
isOpen: boolean;
toggle: () => void;
selected: string;
select: (value: string) => void;
};
const SelectContext = createContext<SelectContextType | null>(null);
// Custom hook for safe usage
export const useSelectContext = () => {
const context = useContext(SelectContext);
if (!context) throw new Error("Select.* components must be used within Select.");
return context;
};
```
### Step 2: Assemble Parent and Child Components
typescript
```typescript
// Select.tsx
export function Select({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState("");
const value = {
isOpen,
toggle: () => setIsOpen(!isOpen),
selected,
select: (val: string) => {
setSelected(val);
setIsOpen(false);
}
};
return (
<SelectContext.Provider value={value}>
<div className="select-root">{children}</div>
</SelectContext.Provider>
);
}
// Child Components (Sub Components)
Select.Trigger = function Trigger({ children }: { children: React.ReactNode }) {
const { toggle, selected } = useSelectContext();
return <button onClick={toggle}>{selected || children}</button>;
};
Select.Menu = function Menu({ children }: { children: React.ReactNode }) {
const { isOpen } = useSelectContext();
if (!isOpen) return null;
return <ul className="select-menu">{children}</ul>;
};
Select.Option = function Option({ value, children }: { value: string, children: React.ReactNode }) {
const { select } = useSelectContext();
return <li onClick={() => select(value)}>{children}</li>;
};
```
### Step 3: Usage
Now Chris has escaped Prop Hell. He can freely place the elements he wants, where he wants them.
typescript
```typescript
// ✅ Readable and Flexible
<Select>
<Select.Trigger>Open Menu</Select.Trigger>
<Select.Menu>
<div className="search-box">You can even put a search bar here</div>
<Select.Option value="apple">Apple 🍎</Select.Option>
<Select.Option value="banana">Banana 🍌</Select.Option>
</Select.Menu>
</Select>
```
## 3\. Headless UI: Logic Without Style
The structure has become flexible thanks to Compound Components, but if styles (e.g., className="select-menu") are still hardcoded inside, it's difficult to use in other design systems.
Headless UI goes one step further.
"I'll provide the functionality (logic), you apply the style (UI)."
Representative libraries include Radix UI, Headless UI (Tailwind), and React Aria.
### Conceptual Code of a Headless Component
typescript
```typescript
// useSelect.ts (Separate logic into a Hook)
function useSelect(items) {
const [isOpen, setIsOpen] = useState(false);
// ... Complex logic like keyboard navigation, focus management, ARIA attributes ...
return {
isOpen,
triggerProps: {
'aria-expanded': isOpen,
onClick: toggle
},
menuProps: {
role: 'listbox'
}
};
}
```
Developers can take this hook and apply designs using whatever they like, whether it's styled-components or Tailwind CSS. This is the pinnacle of reusability.
## 4\. When to Use Which Pattern?
| | | |
| --- | --- | --- |
| Pattern | Characteristics | Recommended Situation |
| Monolithic | Everything is one chunk. Controlled via Props. | Simple atomic components like buttons, inputs. |
| Compound | Parent-Child cooperation. Flexible structure. | UIs with complex internal structures like Dropdowns, Accordions, Tabs, Modals. |
| Headless | No style, logic only. | When building shared libraries where design requirements vary wildly or Accessibility (A11y) is critical. |
## Key Takeaways
Problem: Stuffing all requirements into Props turns a component into an unmaintainable monster.
Compound Component: Uses <Context> to share state and allows the user to assemble child components (Select.Option) themselves. This is Inversion of Control.
Headless UI: Completely separates function and style to maximize design freedom.
* * *
Now that we've made the structure flexible, it's time to separate business logic from UI code. If useEffect and return <div> are mixed in one component, it's hard to read.
Let's look at the Custom Hook Pattern, the modern interpretation of the "Presentational & Container" pattern, to separate them cleanly.
Continuing in: “Separation of Concerns: Separating View and Business Logic (Custom Hook)”
### 🔗 References
[Radix UI - Primitives](https://www.radix-ui.com/primitives)
[React Docs - Context](https://react.dev/learn/passing-data-deeply-with-context)
[Kent C. Dodds - Compound Components](https://kentcdodds.com/blog/compound-components-with-react-hooks)
[← Read more posts](https://www.visionexperiencedeveloper.com/en)
## Comments (0)
Write a comment
0/1000 characters
Submit
No comments yet. Be the first to comment!
### More in Architecture and Design
[**Building a CI/CD Pipeline with Vercel and Managing Environment Variables** 1/26/2026](https://www.visionexperiencedeveloper.com/en/building-a-cicd-pipeline-with-vercel-and-managing-environment-variables) [**Testing by Layer with Vitest: What to Test and What to Give Up** 1/25/2026](https://www.visionexperiencedeveloper.com/en/testing-by-layer-with-vitest-what-to-test-and-what-to-give-up) [**React Clean Architecture: A 3-Layer Separation Strategy (Domain, Data, Presentation)** 1/24/2026](https://www.visionexperiencedeveloper.com/en/react-clean-architecture-a-3-layer-separation-strategy-domain-data-presentation) [**Frontend DTOs: Building a Mapper Layer Unshaken by Backend API Changes** 1/23/2026](https://www.visionexperiencedeveloper.com/en/frontend-dtos-building-a-mapper-layer-unshaken-by-backend-api-changes) [**Separation of Concerns: Separating View and Business Logic (Custom Hook)** 1/22/2026](https://www.visionexperiencedeveloper.com/en/separation-of-concerns-separating-view-and-business-logic-custom-hook)