eduardweb.
Hooks & PatternsAvansat#typescript#react#frontend#design-system

Compound Components în React: Cum construiești un Accordion flexibil ca în shadcn

De Cosmin Rotaru, 22 iul. 2026 · 5 vizualizări · 2 like-uri

Postat 22 iul. 2026
typescript
import React, { createContext, useContext, useState } from "react";

type AccordionContextType = {
  activeValue: string | null;
  toggle: (val: string) => void;
};

const AccordionContext = createContext<AccordionContextType | null>(null);

export const useAccordion = () => {
  const ctx = useContext(AccordionContext);
  if (!ctx) {
    throw new Error("Componentele Accordion must be wrapped in <Accordion>");
  }
  return ctx;
};

export const Accordion = ({ children, defaultValue = null }: { children: React.ReactNode; defaultValue?: string | null }) => {
  const [activeValue, setActiveValue] = useState<string | null>(defaultValue);
  const toggle = (val: string) => setActiveValue((prev) => (prev === val ? null : val));

  return (
    <AccordionContext.Provider value={{ activeValue, toggle }}>
      <div className="w-full divide-y divide-slate-200 border-b">{children}</div>
    </AccordionContext.Provider>
  );
};

Am refăcut recent design system-ul intern pentru o platformă cu peste 12k utilizatori activi lunar și ne-am lovit rapid de o problemă veche. Componenta noastră de <Accordion> primea un array uriaș de obiecte ca prop, iar când echipa de design a cerut iconițe dinamice și badge-uri în header, codul a devenit de neîntreținut. Mutarea la pattern-ul de compound components – cel folosit intensiv de biblioteci precum Radix UI sau shadcn/ui – ne-a salvat proiectul și a redus boilerplate-ul de styling cu aproape 40%.

De ce eșuează modelul monolitic?

Când creezi o componentă tip <Accordion items={data} />, totul pare curat în prima zi. Însă, pe măsură ce aplicația crește, cerințele se complică:

  • Un item are nevoie de un buton de delete în dreapta titlului.
  • Alt item are un skeleton loader până vin datele din backend.
  • Un altul trebuie să dezactiveze acțiunea de toggle pe anumite condiții de business.

Dacă încerci să acoperi toate cazurile prin prop-uri (renderHeader, headerClassName, customIcon), ajungi rapid la o monstrozitate de API. Pierzi complet lizibilitatea JSX-ului și flexibilitatea pe care ți-o oferă React.

Arhitectura bazată pe Context și Slot-uri

Pattern-ul de compound components rezolvă problema prin delegarea controlului de rendering către consumator, în timp ce starea de deschis/închis rămâne încapsulată curat.

Compoziția arată impecabil în codul de zi cu zi:

<Accordion defaultValue="item-1">
  <AccordionItem value="item-1">
    <AccordionTrigger>Setări profil</AccordionTrigger>
    <AccordionContent>Formularul tău aici...</AccordionContent>
  </AccordionItem>
</Accordion>

Mecanismul din spate se bazează pe două niveluri de context React (sau unul singur combinat cu un sub-context per item):

  1. Contextul Părinte (AccordionContext): Ține starea globală (activeValue, funcția toggle).
  2. Contextul de Item (AccordionItemContext): Transmite valoarea specifică acelui tab către copii direct subordonați (Trigger și Content).

Astfel, <AccordionTrigger> știe automat dacă este deschis comparând propriul itemValue cu activeValue din părinte. Nu mai e nevoie să pasezi isOpen={true} manual pe fiecare sub-element.

Cum gestionăm accesibilitatea (WAI-ARIA)

Flexibilitatea de layout nu trebuie să strice accesibilitatea. Într-un compound component bine scris, legătura dintre trigger și content se face prin atributul data-state și ID-uri potrivite:

  • AccordionTrigger primește aria-expanded={isOpen} și gestionează evenimentele de tastatură (Enter / Space).
  • AccordionContent primește role="region" și aria-hidden={!isOpen}.

Pentru animații fluide de height (de la 0 la auto), stilizăm direct pe baza atributului data-state="open | closed". Dacă folosești Tailwind CSS, devine extrem de simplu cu clase de tip data-[state=open]:animate-accordion-down.

Trade-off-uri reale

Să fim sinceri, niciun pattern nu vine fără minusuri.

Ce câștigi:

  • Flexibilitate totală de markup: Poți pune div-uri, iconițe sau tooltip-uri oriunde în trigger, fără să modifici componenta bază.
  • Separare clară: Logica de stare stă în părinte, în timp ce copiii se ocupă exclusiv de structura HTML și stilizare.

Ce pierzi:

  • Developer Experience fragil pentru juniori: Dacă un coleg pune un <AccordionTrigger> în afara unui <AccordionItem>, aplicația va crapa la runtime dacă nu ai pus o verificare explicită în hook.
  • Verbozitate la liste simple: Pentru 3 întrebări simple de FAQ unde toate arată identic, scrii mai mult JSX decât ai scrie cu o componentă ce acceptă un simplu array.

La noi pe proiect, pentru cazurile simple am creat un wrapper subțire peste componenta compound, dar baza a rămas neschimbată. Așa am obținut un echilibru bun între viteză de dezvoltare și flexibilitate.

Voi ce abordare folosiți pentru componentele de UI complexe? Preferați structura modulară de compound components sau mergeți pe componente monolitice cu render props?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

Doar membrii comunității pot lăsa comentarii.