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

7.5 KiB

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

<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

// ❌ 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 tag, and the Headless UI concept, which completely separates style from logic. 1. Wisdom from HTML Let's look at the HTML tag.

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, and , work together. manages the state (selected value), and 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

// 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

// 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

// ✅ 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

// 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 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

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 React Docs - Context Kent C. Dodds - Compound Components

← Read more posts

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 Testing by Layer with Vitest: What to Test and What to Give Up 1/25/2026 React Clean Architecture: A 3-Layer Separation Strategy (Domain, Data, Presentation) 1/24/2026 Frontend DTOs: Building a Mapper Layer Unshaken by Backend API Changes 1/23/2026 Separation of Concerns: Separating View and Business Logic (Custom Hook) 1/22/2026