import React, { createContext, useContext, useState } from 'react';
const AccordionContext = createContext<{
activeValue: string | null;
setActiveValue: (val: string | null) => void;
} | null>(null);
const ItemContext = createContext<{ value: string } | null>(null);
export function Accordion({ children, defaultValue = null }: { children: React.ReactNode; defaultValue?: string | null }) {
const [activeValue, setActiveValue] = useState<string | null>(defaultValue);
return (
<AccordionContext.Provider value={{ activeValue, setActiveValue }}>
<div className="border border-slate-200 rounded-lg divide-y divide-slate-200">{children}</div>
</AccordionContext.Provider>
);
}
export function AccordionItem({ value, children }: { value: string; children: React.ReactNode }) {
return (
<ItemContext.Provider value={{ value }}>
<div className="w-full">{children}</div>
</ItemContext.Provider>
);
}
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
const group = useContext(AccordionContext);
const item = useContext(ItemContext);
if (!group || !item) throw new Error('AccordionTrigger must be used within AccordionItem');
const isOpen = group.activeValue === item.value;
return (
<button
onClick={() => group.setActiveValue(isOpen ? null : item.value)}
className="w-full flex justify-between p-4 font-medium text-left bg-slate-50 hover:bg-slate-100 transition-colors"
>
{children}
<span className="transition-transform duration-200">{isOpen ? '▲' : '▼'}</span>
</button>
);
}
export function AccordionContent({ children }: { children: React.ReactNode }) {
const group = useContext(AccordionContext);
const item = useContext(ItemContext);
if (!group || !item) throw new Error('AccordionContent must be used within AccordionItem');
const isOpen = group.activeValue === item.value;
if (!isOpen) return null;
return <div className="p-4 bg-white border-t border-slate-100 text-slate-600">{children}</div>;
}Salutare! M-am lovit recent de un refactoring destul de serios pe o librărie internă de UI, la un proiect cu vreo 12k utilizatori activi. Aveam un <Accordion> clasic, adică un monolit urât cu prop-uri gen title, content, isOpenByDefault, hasIcon și alte nebunii. Când designerul a venit cu ideea să punem un badge și un buton de share fix în interiorul header-ului, toată componenta s-a prăbușit sub propria greutate de prop-uri opționale.
Soluția curată pentru problema asta este design pattern-ul de compound components. Este exact abordarea pe care o vezi în Radix UI sau shadcn/ui. În loc să trântești o singură componentă gigant, spargi totul în sub-componente atomice care colaborează între ele prin intermediul unui context React ascuns.
De ce e superior design-ul ăsta?
La componentele clasice, ești blocat în layout-ul pe care l-a gândit cel care a scris componenta la început. Dacă vrei să schimbi ordinea elementelor sau să adaugi un element custom între titlu și iconiță, trebuie să adaugi un nou prop de tip ReactNode (clasicele slot-uri). Devine un coșmar de mentenanță.
Cu compound components, codul din pagină devine incredibil de declarativ:
- Utilizatorul are control total asupra layout-ului (vrea iconița în stânga? O mută pur și simplu în cod).
- HTML-ul generat respectă semantica corectă mult mai ușor pentru accesibilitate (ARIA).
- Nu mai ai prop drilling de la părinte la copii.
Am aplicat refactoringul ăsta pe bucata de configurator de produse din aplicație și am economisit cam 30% la timpul de implementare pentru ecranele noi. Motivul? Nu a mai trebuit să modificăm componenta de bază pentru fiecare excepție de design.
Trade-off-ul sincer
Nimic nu e gratis în meseria asta. Am observat că acest pattern vine la pachet cu un pic de boilerplate. Trebuie să scrii mai multe linii de cod în fișierul unde consumi componenta pentru o simplă listă colapsabilă.
Un alt risc major: dacă un developer din echipă pune un <AccordionTrigger> în afara unui <AccordionItem>, aplicația va crăpa la runtime dacă nu ai pus un guard clause solid în hook-ul de context. De asemenea, devine puțin mai greu de urmărit fluxul de date dacă ai structuri foarte adânci de tree-uri DOM. Totuși, flexibilitatea câștigată merită efortul în 90% din cazuri.
Cum arată implementarea sub capotă
Ideea e simplă. Avem un context principal în <Accordion> care ține minte care item este deschis (sau o listă de iteme, dacă e multi-select). Fiecare <AccordionItem> își generează un ID unic și își expune propria stare prin context către trigger și conținut. Aruncă o privire pe codul de mai jos ca să vezi cum se leagă piesele de puzzle.
Cum abordați voi genul ăsta de UI în proiectele mari? Preferați flexibilitatea asta declarativă sau mergeți pe componente mai simple, monolitice, ca să limitați libertatea colegilor de a strica design-ul?