import React, { createContext, useContext, useState } from 'react';
const AccordionContext = createContext(null);
export const Accordion = ({ children, defaultValue }) => {
const [openItem, setOpenItem] = useState(defaultValue);
return (
<AccordionContext.Provider value={{ openItem, setOpenItem }}>
<div className="border rounded">{children}</div>
</AccordionContext.Provider>
);
};
export const AccordionItem = ({ value, children }) => {
return <div className="border-b last:border-0">{children}</div>;
};
export const AccordionTrigger = ({ value, children }) => {
const { openItem, setOpenItem } = useContext(AccordionContext);
return (
<button
onClick={() => setOpenItem(openItem === value ? null : value)}
className="w-full p-4 text-left font-bold"
>
{children}
</button>
);
};Am lucrat recent la un sistem de design pentru un proiect cu vreo 15k useri activi și m-am lovit din nou de eterna problemă a componentelor rigide. Știi și tu cum e: începi cu un <Accordion items={data} /> simplu, dar după două sprinturi vine clientul și vrea un icon de info doar la al treilea rând, sau un buton de 'Export' în header-ul secțiunii. Dacă mergi pe varianta cu props, ajungi rapid la un monstru de cod plin de if-uri și flag-uri de tipul showIconOnThirdItem. E oribil de întreținut.
Soluția pe care o folosesc de câțiva ani, și pe care o vezi implementată excelent în librării precum shadcn/ui sau Radix, este pattern-ul de Compound Components. Ideea e simplă: spargi componenta în bucăți mai mici care comunică între ele printr-un context partajat. În loc să ai o singură componentă care face totul, ai un grup de componente care lucrează împreună.
De ce context și nu doar props?
Faza e că ai nevoie de un 'creier' care să știe ce secțiune e deschisă. Dacă pui logica asta în componenta părinte (Accordion), copiii (AccordionItem, AccordionTrigger) trebuie să știe cum să reacționeze fără să le pasezi manual isOpen sau onClick la fiecare în parte. Aici intervine createContext.
Am pățit de multe ori să încerc să evit contextul folosind React.Children.map și cloneElement, dar e o metodă fragilă. Dacă înfășori un AccordionItem într-un div pentru styling, cloneElement nu îl mai găsește și se rupe totul. Contextul e mult mai iertător cu structura DOM-ului.
Implementarea propriu-zisă
Începi prin a defini un context care ține starea (de exemplu, index-ul sau ID-ul item-ului activ). Accordion devine un provider. Apoi, fiecare sub-componentă își extrage ce are nevoie.
Un aspect la care trebuie să fii atent e accesibilitatea. Nu e suficient să dai toggle la un div. Trebuie să legi aria-expanded pe trigger cu aria-hidden pe conținut. Dacă tot scrii codul de la zero, măcar fă-l corect pentru cititoarele de ecran. Eu folosesc de obicei un ID generat cu useId pentru a lega trigger-ul de panoul de conținut.
Trade-off-uri sincere
Nimic nu e perfect. Pattern-ul ăsta vine cu un cost de boilerplate. În loc de o singură linie de cod în fișierul unde apelezi componenta, vei avea vreo 5-10. Pentru unii colegi mai juniori, poate părea confuz la început de ce trebuie să scrie atâtea tag-uri. Totuși, am economisit cam 30% din timpul de refactoring pe UI-ul complex după ce am adoptat stilul ăsta.
Avantajul major e că poți pune orice între Accordion.Trigger și Accordion.Content. Vrei un badge? Îl pui acolo. Vrei un tooltip? Nicio problemă. Nu mai trebuie să modifici componenta de bază de fiecare dată când design-ul se schimbă cu 5 grade.
Concluzie
Compound components schimbă modul în care gândești API-ul unei componente. Treci de la 'ce date îi dau' la 'cum vreau să arate structura'. E mult mai declarativ și mult mai ușor de extins pe termen lung. Dacă proiectul tău e mai mare de un simplu landing page, merită efortul inițial.
Voi ce preferați pentru componentele de UI complexe: flexibilitatea asta totală sau o interfață mai simplă, bazată strict pe obiecte de configurare?