Simplicity vs Flexibility: Designing a scalable dropdown for a UI System
“Keep it simple.”
We all agree with it — until simplicity starts slowing us down.
On a previous project, I shipped a reusable dropdown component for a shared design system built with React and TypeScript. Most of the team liked it.
One senior engineer didn’t.
“This is over-engineered. Why not just stick to one pattern?”
Fair question.
But also… an incomplete one.
This post is not a counter-argument. It’s a case study in trade-offs—and what simplicity actually means at scale.
We weren’t building a one-off dropdown.
We were building a primitive design system:
- Used across multiple applications
- Consumed by developers with different experience levels
- Required to scale with evolving product needs
- Expected to reduce duplication and accelerate delivery
This translated into real constraints:
| Requirement | Why it mattered |
|---|---|
| Ease of use | Teams should implement it in minutes |
| Flexibility | Handle edge cases without rewrites |
| Consistency | Align with design system standards |
| Scalability | Avoid fragmentation across apps |
The tension was inevitable:
👉 Local simplicity vs system-wide flexibility
Instead of committing to a single pattern, I made a deliberate call:
Adopt a hybrid architecture: Compound Components + Render Props
Because each pattern solves a different class of problems:
| Pattern | Strength |
|---|---|
| Compound Components | Declarative, composable, design-system friendly |
| Render Props | Fast, minimal setup, dynamic usage |
The goal wasn’t to be clever—it was to scale developer experience across teams.
<Dropdown
renderTrigger={(open) => (
<button>{open ? "Close": "Open"} Menu</button>)}
items={[
{ label:"Edit", onClick: handleEdit },
{ label:"Delete", onClick: handleDelete },
]}
/>âś” Minimal setup
âś” Ideal for dashboards, internal tools
âś” No need to learn internal structure
<Dropdown>
<Dropdown.Trigger>
<button>Options</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item onClick={handleEdit}>Edit</Dropdown.Item>
<Dropdown.Separator/>
<Dropdown.Item onClick={handleDelete}>Delete</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>âś” Fully composable
âś” Aligns with design system patterns
âś” Supports complex UI scenarios
At its core, the component centralizes behavior and exposes two rendering strategies:
const DropdownContext = React.createContext(null);
export const Dropdown = ({ children, renderTrigger, items }) => {
const [open, setOpen] = useState(false);
const contextValue= {
open,
toggle: () => setOpen((prev) => !prev),
};
// Render props mode
if (renderTrigger && items) {
return (
<div>
{renderTrigger(open)}
{open && (
<ul>
{items.map((item, i) => (
<li key={i} onClick={item.onClick}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
// Compound mode
return (
<DropdownContext.Provider value={contextValue}>
{children}
</DropdownContext.Provider>
);
};This is where the trade-offs become real.
Let’s be honest:
- Higher cognitive load
- Two APIs to maintain
- Potential inconsistency across teams
- Larger surface area for bugs
And the strongest critique:
“We should optimize for simplicity first.”
That argument is valid—but incomplete.
| Type | Meaning |
|---|---|
| Local Simplicity | Easy to understand in isolation |
| System Simplicity | Easy to use across many scenarios |
A single-pattern approach optimizes for:
👉 Local simplicity
The hybrid approach optimizes for:
👉 System-wide simplicity
Scenario A — Only Compound Components
<Dropdown>
<Dropdown.Trigger>...</Dropdown.Trigger>
<Dropdown.Menu>...</Dropdown.Menu>
</Dropdown>Outcome:
- More boilerplate
- Slower iteration
- Repeated patterns across teams
Scenario B — Only Render Props
<Dropdown
renderTrigger={...}
items={[...]}
renderItem={...}
{...}
/>You lose:
- Composability
- Design system enforcement
- Fine-grained UI control
A design system component is not just a component.
It’s an interface for multiple developers with different needs.
And good interfaces:
- Scale across use cases
- Reduce decision fatigue
- Allow both speed and depth
Start simple → scale when needed
No rewrites required
| Developer | Needs |
|---|---|
| Product engineer | Ship fast |
| UI engineer | Customize deeply |
| Design system team | Enforce consistency |
Instead of:
- Copy-pasting dropdown logic
- Rebuilding variants per app
We centralize:
👉 Behavior + flexibility in one place
When requirements evolve:
- Nested menus
- Async loading
- Accessibility improvements
- Keyboard navigation
The foundation already supports it.
If you’re building a shared UI system, ask:
- Will this component be reused across multiple products?
- Do teams have different levels of complexity needs?
- Is developer velocity a priority?
- Will requirements evolve over time?
If most answers are yes:
👉 Consider a hybrid approach.
Be pragmatic:
Avoid hybrid patterns if:
- You’re building a small app
- There’s only one clear use case
- The team needs strict conventions
- Reuse is unlikely
- Separation of concerns
- Logic (state, context)
- Rendering (patterns)
- Explicit APIs
- Props define behavior clearly
- No implicit magic
- Single source of truth
- Shared internal state
- Simplicity is contextual—not absolute
- Design systems require system thinking, not component thinking
- Hybrid patterns can:
- Improve developer experience
- Reduce duplication
- Enable scalability
- But they introduce:
- Maintenance overhead
- Architectural responsibility
That engineer wasn’t wrong.
They were optimizing for clarity today. I was optimizing for velocity tomorrow.
And the truth is, both perspectives matter.
But at some point, the shift happens:
You stop designing components.
And when that happens, simplicity is no longer about fewer lines of code — it's about fewer constraints across the entire ecosystem.