import React, { createContext, useContext, useState } from "react";
const AccordionContext = createContext<{ active: string | null; toggle: (id: string) => void } | null>(null);
const ItemContext = createContext<{ id: string } | null>(null);
export function Accordion({ children, defaultValue = null }: { children: React.ReactNode; defaultValue?: string | null }) {
const [active, setActive] = useState<string | null>(defaultValue);
const toggle = (id: string) => setActive((prev) => (prev === id ? null : id));
return <AccordionContext.Provider value={{ active, toggle }}>{children}</AccordionContext.Provider>;
}
export function AccordionItem({ id, children }: { id: string; children: React.ReactNode }) {
return <ItemContext.Provider value={{ id }}>{children}</ItemContext.Provider>;
}
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
const root = useContext(AccordionContext);
const item = useContext(ItemContext);
if (!root || !item) throw new Error("AccordionTrigger must be used inside AccordionItem");
return (
<button type="button" onClick={() => root.toggle(item.id)} aria-expanded={root.active === item.id}>
{children}
</button>
);
}
export function AccordionContent({ children }: { children: React.ReactNode }) {
const root = useContext(AccordionContext);
const item = useContext(ItemContext);
if (!root || !item) throw new Error("AccordionContent must be used inside AccordionItem");
if (root.active !== item.id) return null;
return <div role="region">{children}</div>;
}Am văzut prea multe componente de UI care au început cu două props nevinovate (title și content) și au sfârșit cu monștri de genul rightIconClassName, disableHeaderClick sau renderCustomHeaderWrapper. Dacă ai ajuns să pasezi 12 props doar ca să așezi un amărât de badge lângă un titlu, e clar că ai o problemă de arhitectură.
Soluția pe care o folosesc biblioteci ca Radix UI sau shadcn/ui este pattern-ul de Compound Components. Ideea e simplă: spargi o componentă monolitică într-un set de componente mai mici care colaborează în mod invizibil prin React Context, lăsând structura DOM-ului și compunerea pe seama celui care consumă codul.
Cum funcționează sub capotă
Secretul stă în decuplarea stării de arborele vizual. Avem nevoie de un context părinte (AccordionContext) care știe ce element este deschis (sau ce elemente sunt deschise, dacă permiți selecție multiplă), și un context intermediar (AccordionItemContext) care reține identificatorul unic al fiecărui rând.
În practică, compunerea arată așa:
<Accordion>ține starea globală (openItem,setOpenItem) și expune un provider.<Accordion.Item value="tab-1">ține contextul local (id-ul curent).<Accordion.Trigger>ascultă de click, citește id-ul din contextul local și apelează funcția de toggle din contextul rădăcină.<Accordion.Content>citește id-ul local și verifică dacă se potrivește cu cel activ din rădăcină. Dacă da, randează copiii; dacă nu, se ascunde sau iese complet din DOM.
Consumatorul codului are control complet. Vrea un <div> în jurul trigger-ului? Poate să-l pună. Vrea o iconiță poziționată absolut? Nicio problemă, nu trebuie să adaugi încă un prop bizar în componenta de bază.
Trade-off-uri pe care nu ți le spune nimeni
La un dashboard enterprise cu vreo 50k utilizatori activi, am rescris toate tab-urile și acordeoanele pe acest pattern. Am eliminat peste 400 de linii de cod redundant și am scăpat de 80% din tichetele de UI de tipul „mai adaugă un buton în colțul din dreapta”. Totuși, vin la pachet și dezavantaje clare.
În primul rând, cuplarea implicită. Sub-componentele depind de context. Dacă un junior ia <Accordion.Trigger> și îl trântește în altă parte a paginii fără să aibă părintele <Accordion.Item>, aplicația crapă la runtime dacă nu ai aruncat o eroare explicită în custom hook-ul tău (useAccordionItemContext).
În al doilea rând, TypeScript devine mai zgomotos. Dacă vrei să oferi o experiență impecabilă de tip Radix (suport complet pentru asChild sau slot-uri native via @radix-ui/react-slot), intri într-un labirint de generics și type overrides. Pentru proiecte mici sau interne, polimorfismul ăsta e adesea overkill; un simplu set de sub-componente pe bază de forwardRef își face treaba în 95% din cazuri.
Compound components strălucesc când construiești un design system sau componente reutilizate în zeci de locuri cu mici variații vizuale. Dacă ai un ecran obscur cu un singur acordeon static, nu te complica — un simplu isOpen ? <A> : <B> e suficient.
Voi cum abordați componentele astea: mergeți pe Radix/shadcn direct sau vă scrieți propriile primitive când aveți nevoie de control total?