Luca Mele
Luca Mele

Architecture

Smart vs. Dumb Component: la separazione che rende il codice manutenibile

Smart vs. Dumb Component: la separazione che rende il codice manutenibile
Torna a tutti gli articoli
·7 min di lettura

Se c'è un pattern che impongo in ogni team che guido, è la separazione tra smart e dumb component. Sembra semplice. In pratica, è la singola decisione architetturale con il maggiore impatto che si possa prendere in una codebase frontend — e i team che la ignorano ne pagano il prezzo in ogni sprint.

L'idea non è nuova, ma trovo che venga costantemente fraintesa. Non si tratta solo di «componenti con stato» versus «componenti senza stato». Si tratta di una separazione fondamentale delle responsabilità che determina quanto il codice sia riutilizzabile, testabile e manutenibile.

Cos'è un Dumb Component?

Un dumb component è generico e completamente agnostico rispetto alla logica di business. Non sa da dove vengono i suoi dati. Non recupera nulla. Non sa cos'è un codice postale, cosa fa un carrello, o quale API espone il backend. Riceve dati tramite props, renderizza UI e riporta le interazioni utente tramite callback. Fine.

Pensatelo come una funzione pura per la UI: stesso input, stesso output, ogni volta. Un componente SelectableList non si preoccupa se sta renderizzando codici postali, categorie di prodotti o nomi di paesi. La sua interfaccia è definita in termini di concetti generici — item con label e value, un callback di selezione, magari un testo placeholder.

Sfumatura importante: i dumb component possono contenere altri dumb component. Una PostalCodeList può essere un dumb component che aggrega una SelectableList con UI aggiuntiva — una label, una bandiera del paese, layout. Non trasforma dati e non mappa modelli. Compone solamente. Lo smart component sopra di lei è quello che recupera i dati grezzi dall'API e li mappa nella forma che il dumb si aspetta. Questa è la separazione: lo smart trasforma il modello, il dumb aggrega la presentazione.

Questo è il punto critico: il dumb component definisce la propria interfaccia. È il provider. Dice «datemi item con questa forma, e li renderizzerò.» Il parent deve adattare i propri dati, non il contrario.

// Dumb component: generic, reusable, knows nothing about postal codes
interface SelectableListProps {
  items: { label: string; value: string }[];
  onSelect: (value: string) => void;
  placeholder?: string;
}

export const SelectableList: FC<SelectableListProps> = ({
  items,
  onSelect,
  placeholder = 'Select...',
}) => (
  <ul role="listbox" aria-label={placeholder}>
    {items.map((item) => (
      <li key={item.value} role="option" onClick={() => onSelect(item.value)}>
        {item.label}
      </li>
    ))}
  </ul>
);

// Also dumb! Aggregates SelectableList with postal-code-specific UI
// Adds a label and flag — but does NOT transform data, only composes
export interface PostalCodeListProps {
  items: SelectableListProps['items'];
  onSelect: (value: string) => void;
  label?: string;
  countryFlag?: string;
}

export const PostalCodeList: FC<PostalCodeListProps> = ({
  items,
  onSelect,
  label = 'Postal code',
  countryFlag,
}) => (
  <div>
    <span>{countryFlag} {label}</span>
    <SelectableList items={items} onSelect={onSelect} placeholder={label} />
  </div>
);

Cos'è uno Smart Component?

Uno smart component è il consumer. Conosce il dominio di business. Recupera dati dalle API, gestisce lo stato, gestisce gli effetti collaterali e trasforma i dati grezzi nella forma che i dumb component si aspettano. È il collante tra il backend e la UI.

Un PostalCodePicker è uno smart component. Sa che i codici postali esistono, che vengono da un'API, e che sono filtrati per paese. Recupera i dati grezzi e li mappa nel formato generico di items che il dumb PostalCodeList si aspetta. Il dumb component non vede mai il modello della tua API — vede solo props pulite e pre-trasformate. Ecco perché il dumb resta riutilizzabile: non ha idea da dove vengano i dati o che forma avessero prima.

// Smart component: fetches data AND transforms the model for the dumb component
import { PostalCodeList } from './PostalCodeList';

export const PostalCodePicker: FC<{ country: string }> = ({ country }) => {
  const [codes, setCodes] = useState<PostalCode[]>([]);

  useEffect(() => {
    fetchPostalCodes(country).then(setCodes);
  }, [country]);

  // Smart transforms raw API data → shape the dumb component expects
  const items = codes.map((c) => ({
    label: `${c.code} — ${c.city}`,
    value: c.code,
  }));

  return (
    <PostalCodeList
      items={items}
      onSelect={(code) => updateAddress({ postalCode: code })}
      countryFlag={getFlagEmoji(country)}
    />
  );
};

L'analogia del provider

Insegno questo come pattern provider/consumer. Il componente figlio è il provider — fornisce un'interfaccia pulita e ben definita. Il componente genitore è il consumer — consuma quell'interfaccia trasformando i propri dati per adattarli.

Questo inverte il modo in cui la maggior parte degli sviluppatori pensa alle relazioni tra componenti. L'istinto naturale è «il parent decide cosa riceve il child.» Ma in codice ben architettato, il child dichiara di cosa ha bisogno, e il parent è responsabile dell'adattamento. Il parent trasforma i dati del modello verso l'interfaccia props del child. Il parent è responsabile del layout — margin, gap, grid, flex.

Perché è importante? Perché quando il child definisce il contratto, il child resta generico. Può essere riutilizzato ovunque. Ma quando il parent detta la forma, il child diventa accoppiato a un caso d'uso specifico.

// The child (dumb) defines the interface — it is the PROVIDER
// The parent (smart) adapts data to that interface — it is the CONSUMER

// ❌ Wrong: dumb component receives raw API data and maps it internally
<PostalCodeList codes={rawApiResponse} format="CH" />
// Now PostalCodeList has to know how to transform your API model — it's coupled

// ✅ Right: smart transforms, dumb aggregates
// Smart maps the model to the shape the dumb expects
const items = apiResponse.map((c) => ({ label: `${c.code} — ${c.city}`, value: c.code }));
// Dumb just composes other dumb components (label + SelectableList)
<PostalCodeList items={items} onSelect={handleSelect} countryFlag="🇨🇭" />

Perché questa separazione cambia tutto

Storybook e documentazione: i dumb component vivono perfettamente in Storybook. Passi props, vedi il risultato. Nessuna API mockata, nessun wrapper di provider, nessun contesto di autenticazione. Questo significa che i designer possono revisionare i componenti in isolamento, la QA può testare i casi limite senza configurare ambienti completi, e i nuovi sviluppatori possono sfogliare la libreria di componenti e capire cosa è disponibile.

Codice condiviso tra progetti: i dumb component sono portabili per definizione. Quella SelectableList funziona nel checkout e-commerce, nella dashboard admin e nell'app mobile. Poiché non sa nulla del business, appartiene a tutti. Li mettiamo in una libreria di componenti condivisa e diventano veri asset riutilizzabili invece di template copia-incolla.

Il testing è semplice: testare un dumb component è banale — renderizzare con props, verificare l'output. Nessun mocking di API, nessuno stato asincrono da aspettare, nessun context provider da wrappare. Testare uno smart component è anche più pulito perché si testa solo la logica dati e l'orchestrazione, non i dettagli di rendering.

Refactoring senza paura: quando la forma della API cambia, aggiorni la logica di trasformazione dello smart component. Il dumb component non cambia. Quando il team di design aggiorna lo stile delle liste, aggiorni il dumb component. Lo smart component non cambia. Le modifiche sono isolate perché le responsabilità sono isolate.

Quando separare

Non si inizia separati. Si inizia con tutto in un componente. Questo è importante — la separazione prematura è solo astrazione prematura travestita. Questo si collega direttamente al principio YAGNI di cui ho scritto in Perché YAGNI batte DRY. Non estraete un dumb component perché pensate di riutilizzarlo un giorno. Estraetelo quando ne avete davvero bisogno.

Separo quando uno di questi criteri diventa vero: il componente recupera dati E renderizza UI complessa, la parte visiva potrebbe essere riutilizzata in un altro contesto con dati diversi, il file sta diventando troppo grande (che spesso segnala una single responsibility violata), o voglio questo componente in Storybook.

L'HTML puramente strutturale — un div wrapper per il layout, un tag section per la semantica — non ha bisogno del proprio componente. Non estraete le cose solo perché sono HTML. Estraete le cose perché rappresentano un pezzo di UI significativo e riutilizzabile con un'interfaccia chiara.

Il vero guadagno

I team che applicano questa separazione consegnano più velocemente nel tempo. Non al giorno uno — al giorno uno, scrivere tutto inline è più veloce. Ma al terzo mese, il team con una separazione pulita ha una libreria di dumb component testati e documentati che può comporre in nuove feature in ore invece di giorni. Il team senza ha un mucchio di smart component intrecciati dove ogni modifica rischia di rompere qualcosa di non correlato.

Non si tratta di purezza architetturale. Si tratta di rendere la seconda feature meno costosa della prima. Questo è tutto il senso della buona architettura — ridurre il costo del cambiamento. E nessun pattern lo fa in modo più affidabile che tenere la logica di business fuori dai componenti UI.