Introduzione

Quando si avvia un progetto React, le prime righe di codice sono facili. Il problema arriva dopo: file sparsi ovunque, componenti duplicati, logica di autenticazione in dieci posti diversi, e ogni developer del team che struttura le cose a modo suo.

La differenza tra un progetto React mantenibile e un disastro tecnico non sta nella conoscenza di React — sta nell'architettura della codebase. Una struttura chiara non serve solo a "tenere ordine": determina la velocità con cui il team può sviluppare nuove feature, la facilità di onboarding di nuovi developer, e la capacità di scalare il progetto senza riscrivere tutto.

In questa guida vediamo come strutturare un progetto React professionale: organizzazione delle cartelle, gestione dello stato globale (lingua, autenticazione, tema), convenzioni di naming, e strategie di documentazione per lavorare in team. Non una teoria astratta, ma le scelte concrete che fanno la differenza nei progetti reali.

Se parti da zero con React, ho pubblicato un corso gratuito completo su GitHub: React da Zero. Copre le fondamenta del framework prima di affrontare l'architettura di progetto.


Perché la struttura del progetto React è critica

React è un framework non-opinionated: non impone una struttura specifica. Questa libertà è un vantaggio per progetti piccoli, ma diventa un problema quando:

Il costo di una cattiva architettura: in un progetto React mal strutturato, aggiungere una feature che dovrebbe richiedere 2 ore può richiederne 8, perché prima devi capire dove mettere il codice, cosa riusare, e come non rompere il resto.

Una buona architettura risponde a queste domande in modo automatico:


La struttura delle cartelle: feature-based vs layer-based

Esistono due approcci principali per organizzare i file in un progetto React.

Layer-based (sconsigliato per progetti oltre i 20 componenti)

src/
├── components/
│   ├── Button.jsx
│   ├── Card.jsx
│   ├── UserProfile.jsx
│   └── ProductList.jsx
├── hooks/
│   ├── useAuth.js
│   └── useTheme.js
├── services/
│   └── api.js
└── utils/
    └── formatters.js

Problema: quando il progetto cresce, components/ diventa una lista di centinaia di file senza gerarchia. Trovare il componente giusto diventa una ricerca nel filesystem.

Feature-based (consigliato)

src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── LoginForm.jsx
│   │   │   └── AuthProvider.jsx
│   │   ├── hooks/
│   │   │   └── useAuth.js
│   │   ├── services/
│   │   │   └── authService.js
│   │   └── index.js
│   ├── products/
│   │   ├── components/
│   │   │   ├── ProductCard.jsx
│   │   │   ├── ProductList.jsx
│   │   │   └── ProductDetail.jsx
│   │   ├── hooks/
│   │   │   └── useProducts.js
│   │   └── index.js
│   └── user/
│       ├── components/
│       │   └── UserProfile.jsx
│       └── hooks/
│           └── useUserPreferences.js
├── shared/
│   ├── components/
│   │   ├── Button/
│   │   │   ├── Button.jsx
│   │   │   ├── Button.module.css
│   │   │   └── Button.test.jsx
│   │   └── Card/
│   │       └── Card.jsx
│   ├── hooks/
│   │   └── useMediaQuery.js
│   └── utils/
│       └── formatters.js
├── contexts/
│   ├── LanguageContext.jsx
│   ├── ThemeContext.jsx
│   └── AuthContext.jsx
├── config/
│   ├── constants.js
│   └── routes.js
└── App.jsx

Vantaggi concreti:


Gestire lo stato globale: Context API per lingua, tema e autenticazione

Le informazioni che servono ovunque nell'app (lingua corrente, utente loggato, tema dark/light) non vanno passate via props a cascata. La soluzione nativa di React è la Context API.

Esempio: LanguageContext

// contexts/LanguageContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const LanguageContext = createContext();

export function LanguageProvider({ children }) {
  const [language, setLanguage] = useState(() => {
    // Recupera la lingua salvata o usa quella del browser
    return localStorage.getItem('language') || navigator.language.split('-')[0];
  });

  useEffect(() => {
    // Persisti la scelta
    localStorage.setItem('language', language);
  }, [language]);

  const value = {
    language,
    setLanguage,
    t: (key) => translations[language]?.[key] || key // Funzione di traduzione base
  };

  return (
    <LanguageContext.Provider value={value}>
      {children}
    </LanguageContext.Provider>
  );
}

export function useLanguage() {
  const context = useContext(LanguageContext);
  if (!context) {
    throw new Error('useLanguage deve essere usato dentro LanguageProvider');
  }
  return context;
}

// File di traduzioni (esempio minimale)
const translations = {
  it: {
    'welcome': 'Benvenuto',
    'login': 'Accedi'
  },
  en: {
    'welcome': 'Welcome',
    'login': 'Login'
  }
};

Esempio: ThemeContext (dark mode)

// contexts/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    const saved = localStorage.getItem('theme');
    if (saved) return saved;
    
    // Usa la preferenza di sistema se disponibile
    return window.matchMedia('(prefers-color-scheme: dark)').matches 
      ? 'dark' 
      : 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme deve essere usato dentro ThemeProvider');
  }
  return context;
}

Esempio: AuthContext

// contexts/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const AuthContext = createContext();

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Verifica se esiste una sessione valida
    const token = localStorage.getItem('authToken');
    if (token) {
      // Chiama API per verificare il token e recuperare i dati utente
      fetch('/api/me', {
        headers: { 'Authorization': `Bearer ${token}` }
      })
        .then(res => res.json())
        .then(data => setUser(data))
        .catch(() => localStorage.removeItem('authToken'))
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const login = async (credentials) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials)
    });
    const data = await response.json();
    localStorage.setItem('authToken', data.token);
    setUser(data.user);
  };

  const logout = () => {
    localStorage.removeItem('authToken');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, loading }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth deve essere usato dentro AuthProvider');
  }
  return context;
}

Composizione dei provider in App.jsx

// App.jsx
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { Router } from './Router';

function App() {
  return (
    <ThemeProvider>
      <LanguageProvider>
        <AuthProvider>
          <Router />
        </AuthProvider>
      </LanguageProvider>
    </ThemeProvider>
  );
}

export default App;

Ora ogni componente può accedere a lingua, tema e autenticazione con:

import { useLanguage } from '@/contexts/LanguageContext';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';

function MyComponent() {
  const { t, language, setLanguage } = useLanguage();
  const { theme, toggleTheme } = useTheme();
  const { user, logout } = useAuth();

  return (
    <div>
      <h1>{t('welcome')}, {user?.name}</h1>
      <button onClick={toggleTheme}>
        Tema: {theme}
      </button>
      <button onClick={() => setLanguage(language === 'it' ? 'en' : 'it')}>
        Lingua: {language}
      </button>
    </div>
  );
}

Convenzioni di naming e organizzazione dei file

Per lavorare in team servono regole chiare e automatiche. Queste sono le convenzioni più diffuse nei progetti React professionali:

Componenti

Hooks personalizzati

Utilities e servizi

Costanti e configurazione

Esempio di struttura completa di una feature

features/products/
├── components/
│   ├── ProductCard/
│   │   ├── ProductCard.jsx
│   │   ├── ProductCard.module.css
│   │   └── ProductCard.test.jsx
│   ├── ProductList.jsx
│   └── ProductDetail.jsx
├── hooks/
│   ├── useProducts.js
│   └── useProductFilters.js
├── services/
│   └── productService.js
├── utils/
│   └── productHelpers.js
└── index.js  // Esporta solo l'API pubblica della feature

Il file index.js esporta solo ciò che serve fuori dalla feature:

// features/products/index.js
export { ProductList } from './components/ProductList';
export { ProductDetail } from './components/ProductDetail';
export { useProducts } from './hooks/useProducts';

Questo permette import puliti nel resto dell'app:

import { ProductList, useProducts } from '@/features/products';

Documentare il codice per il team

La documentazione non è un PDF. È codice commentato, README nelle cartelle chiave, e convenzioni chiare.

README.md nella root del progetto

Deve contenere:

Esempio minimale:

# Nome Progetto

## Setup

npm install
npm run dev

## Struttura

- `features/`: feature dell'app organizzate per dominio
- `shared/`: componenti, hooks e utils riusabili
- `contexts/`: stato globale (auth, tema, lingua)
- `config/`: costanti e configurazione

## Convenzioni

- Componenti: PascalCase, un file per componente
- Hooks: camelCase con prefisso `use`
- Ogni feature ha un `index.js` che esporta l'API pubblica

## Script

- `npm run dev`: sviluppo locale
- `npm run build`: build di produzione
- `npm run test`: esegui i test

Commenti JSDoc nei file critici

Per funzioni complesse o hook personalizzati, usa JSDoc:

/**
 * Hook per gestire la paginazione dei prodotti con filtri.
 * 
 * @param {Object} options - Opzioni di configurazione
 * @param {number} options.pageSize - Numero di prodotti per pagina (default: 20)
 * @param {string} options.category - Categoria da filtrare (opzionale)
 * @returns {Object} Stato della paginazione e funzioni di controllo
 * @returns {Array} returns.products - Lista prodotti della pagina corrente
 * @returns {number} returns.currentPage - Pagina corrente
 * @returns {Function} returns.nextPage - Funzione per andare alla pagina successiva
 * @returns {Function} returns.prevPage - Funzione per tornare indietro
 * @returns {boolean} returns.loading - Stato di caricamento
 */
export function useProductPagination({ pageSize = 20, category } = {}) {
  // implementazione...
}

README nelle feature complesse

Se una feature ha logica articolata, aggiungi un README.md nella sua cartella:

# Feature: Authentication

Gestisce login, logout, persistenza sessione e protezione delle route.

## Componenti

- `LoginForm`: form di autenticazione
- `AuthProvider`: context provider per lo stato auth

## Hooks

- `useAuth`: accesso allo stato di autenticazione
- `useProtectedRoute`: redirect automatico per route protette

## Flusso di autenticazione

1. L'utente inserisce credenziali in `LoginForm`
2. `authService.login()` chiama `/api/login`
3. Il token viene salvato in localStorage
4. `AuthContext` aggiorna lo stato globale
5. Le route protette diventano accessibili

Path alias: evitare import relativi infiniti

Invece di:

import { Button } from '../../../shared/components/Button/Button';

Configura path alias in vite.config.js (o jsconfig.json per Create React App):

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@features': path.resolve(__dirname, './src/features'),
      '@shared': path.resolve(__dirname, './src/shared'),
      '@contexts': path.resolve(__dirname, './src/contexts'),
      '@config': path.resolve(__dirname, './src/config'),
    }
  }
});

Ora gli import diventano:

import { Button } from '@shared/components/Button/Button';
import { useAuth } from '@contexts/AuthContext';
import { API_BASE_URL } from '@config/constants';

Molto più leggibili e indipendenti dalla posizione del file che importa.


Testing: dove mettere i test

Ogni componente ha il suo file di test co-locato:

ProductCard/
├── ProductCard.jsx
├── ProductCard.module.css
└── ProductCard.test.jsx

Per test di integrazione o end-to-end, crea una cartella __tests__/ alla radice:

src/
├── features/
├── shared/
├── __tests__/
│   ├── integration/
│   │   └── authFlow.test.js
│   └── e2e/
│       └── checkoutFlow.spec.js
└── App.jsx

Linting e formatting: configurazione di team

Per evitare discussioni su stile del codice, configura ESLint e Prettier una volta e basta.

.eslintrc.json (regole React consigliabili):

{
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended"
  ],
  "rules": {
    "react/prop-types": "off",
    "react/react-in-jsx-scope": "off"
  }
}

.prettierrc:

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5"
}

Aggiungi script in package.json:

{
  "scripts": {
    "lint": "eslint src/",
    "format": "prettier --write src/"
  }
}

E configura il pre-commit hook con Husky per formattare automaticamente prima di ogni commit.


Checklist per una codebase React ben strutturata

Prima di mergiare una nuova feature, verifica:

Check Descrizione
✅ Struttura feature-based Nuova feature in features/, componenti shared in shared/
✅ Context per stato globale Lingua, tema, auth gestiti tramite Context API
✅ Path alias configurati Import assoluti con @/ invece di ../../../
✅ Naming coerente PascalCase per componenti, camelCase per hook/utils
✅ Test co-locati Ogni componente ha il suo .test.jsx nella stessa cartella
✅ Documentazione aggiornata README del progetto riflette la struttura attuale
✅ ESLint/Prettier Configurati e eseguiti prima del commit

Conclusione

Strutturare bene un progetto React non è un lusso per progetti enterprise — è una necessità per qualsiasi codebase che deve durare più di qualche mese. Le decisioni prese all'inizio (feature-based vs layer-based, Context API per stato globale, path alias) determinano la velocità di sviluppo e la qualità del codice per tutto il ciclo di vita del progetto.

La differenza concreta: in un progetto ben strutturato, aggiungere una feature richiede sapere solo dove creare la cartella in features/, quali Context importare, e come esportare l'API pubblica. In un progetto mal strutturato, ogni nuova feature è un esercizio di reverse engineering per capire come il team precedente ha fatto le cose.

Inizia dal prossimo progetto. Usa la struttura feature-based, configura i Context per stato globale, aggiungi path alias. Sono due ore di setup che risparmiano settimane di refactoring.

Risorsa aggiuntiva: Se vuoi approfondire le basi di React prima di strutturare progetti complessi, ho pubblicato un corso gratuito completo: React da Zero su GitHub. Copre tutto dalle fondamenta fino agli hook avanzati e tiene traccia dei tuoi progressi.


FAQ

Quando usare Redux invece della Context API? Context API è sufficiente per la maggior parte dei progetti. Redux diventa necessario quando hai logica di stato complessa con molte azioni, devi tracciare ogni cambiamento di stato (debugging con time-travel), o hai necessità di middleware avanzati. Per autenticazione, lingua e tema, Context API è la scelta migliore: più semplice, meno boilerplate, e performance equivalenti se strutturata bene.

Quanti Context posso avere senza problemi di performance? Avere 3-5 Context separati (Auth, Theme, Language, Notifications, User Preferences) è perfettamente accettabile. Il problema non è il numero di Context, ma il fatto che ogni cambiamento in un Context causa il re-render di tutti i componenti che lo consumano. Soluzione: separare i Context per dominio e usare useMemo per evitare re-render inutili nei provider.

La struttura feature-based funziona anche per app piccole? Sì, ma con una semplificazione: per progetti sotto i 20 componenti puoi usare un ibrido con components/, hooks/, utils/ alla radice. Quando superi i 30-40 componenti, migrare a feature-based diventa conveniente. Il momento giusto è quando inizi a scrollare troppo dentro components/ per trovare il file giusto.

Come gestisco componenti condivisi tra più feature? Vanno in shared/components/. Regola pratica: se un componente è usato da 2+ feature diverse, è shared. Se è usato solo dentro una feature, resta nella cartella della feature. Non anticipare: inizia con componenti dentro la feature, e spostali in shared/ solo quando serve davvero.

Dove metto le chiamate API? In file services/ dentro ogni feature. Esempio: features/products/services/productService.js contiene tutte le chiamate API relative ai prodotti. Se hai un client HTTP configurato (es. Axios con interceptor), quello va in shared/services/apiClient.js e viene importato dai singoli service.

Quando creare un hook personalizzato invece di usare direttamente useState? Crea un custom hook quando la stessa logica stateful si ripete in 2+ componenti, oppure quando la logica è complessa (combina più useState, useEffect, e ha side effect). Per uno stato locale semplice usato in un solo componente, useState diretto è perfetto. Non creare hook prematuramente.