Estructura de projecte per feature a React Native

Per què faig servir una estructura de projecte per feature a React Native

La versió curta: per sota d’unes cinc features amb el seu propi estat, les carpetes per tipus (screens/, hooks/, services/) van bé. Per sobre, la mateixa estructura comença a costar més del que estalvia. Aquest post va del perquè, i d’on cau la línia.

85 fitxers per a una sola feature

Aquests són els fitxers TypeScript que té la meva feature d’Auth. Sis pantalles, un store de Redux, un context de React, un hook personalitzat, components de PIN amb stories de Storybook, esquemes de validació de formularis contra una llista negra de contrasenyes comunes, limitació de peticions, un servei de bloqueig, i tests a tots els nivells.

A la majoria de projectes React Native, aquests 85 fitxers estarien repartits per set carpetes diferents. Les pantalles en un lloc, els hooks en un altre, l’slice de l’store en un altre, la validació en un altre. Per entendre com funciona l’autenticació, obriries set carpetes i reconstruiries mentalment les relacions entre fitxers que no estan a prop els uns dels altres.

L’esquema queda polit amb tres o quatre pantalles. Passat això, les relacions es tornen invisibles. El hook d’una feature viu lluny de la pantalla que el fa servir. Les regles de validació són en una carpeta separada del formulari que validen. Revisar una feature vol dir escanejar diverses llistes alfabètiques buscant les peces.

L’estructura per tipus, i per què és l’opció per defecte

Aquesta estructura ja la coneixes:

src/
├── screens/
│   ├── LoginScreen.tsx
│   ├── ProfileScreen.tsx
│   ├── SettingsScreen.tsx
│   └── WorkExperienceScreen.tsx
├── components/
│   ├── PINInput.tsx
│   ├── ProfileCard.tsx
│   └── AlertBox.tsx
├── hooks/
│   ├── useAuth.ts
│   └── useProfile.ts
├── store/
│   ├── authSlice.ts
│   └── profileSlice.ts
└── utils/
    └── dateFormatter.ts

Fitxers agrupats per mena. Per tipus. La majoria de tutorials de React Native ho munten així, i hi ha bones raons. Els col·laboradors nous reconeixen la forma al moment. Un revisor que mira per sobre una prova tècnica veu screens/, hooks/, components/ sense pensar-hi. Els noms de carpeta encaixen amb el vocabulari del framework, i el mateix model mental serveix d’un projecte a un altre. Per a tres o quatre pantalles, n’hi ha prou per mantenir-ho ordenat. Si alguna vegada has fet una prova tècnica, l’estructura de carpetes és una de les primeres coses que mira un revisor, i per tipus és l’opció segura.

L’esquema aguanta mentre l’app és petita. Llavors afegeixes autenticació amb configuració de PIN, verificació per correu, recuperació de contrasenya. Afegeixes gestió del perfil amb pujada de fotos, edició del compte, canvi de contrasenya. De cop screens/ té 25 fitxers, i trobar el hook de la pujada de foto de perfil vol dir escanejar una llista alfabètica de tots els hooks de l’app.

Ara prova d’eliminar una feature. Treu la pantalla de screens/. Troba el seu hook a hooks/. El seu servei a services/. El seu slice de l’store. Els seus components. El seu esquema de validació. Els seus tests, en un arbre __tests__/ a part. Si et deixes un fitxer, tens codi mort que s’hi quedarà durant mesos.

Aquesta és la prova. Si eliminar una feature triga més que construir-la, l’estructura et juga en contra.

Una carpeta per feature

La meva app té 13 features. Cadascuna viu en un sol directori:

src/features/
├── Auth/           # 85 fitxers. Login, registre, PIN, bloqueig
├── Profile/        # API, store, pujada de foto, 5 pantalles
├── Settings/       # Tema, idioma, 3 pantalles
├── Education/      # Store, API, 1 pantalla
├── WorkExperience/ # Store, API, 4 pantalles
├── Home/           # 1 pantalla, 1 export
├── Legal/          # Política de privacitat, T&Cs
├── Permissions/    # Càmera, galeria, pantalles de denegació
├── MockStatus/     # Pantalla d'estat MSW només per a dev
├── PDF/            # Visor de PDF
├── Placeholder/    # Placeholders de xat i de reserves
├── WebView/        # Pantalla webview genèrica
└── Splash/         # Pantalla de splash

La resta queda fora de les features: shared/ per a components i hooks reutilitzables, store/ per a la configuració de Redux, navigation/, httpClients/, utils/, i18n/.

La feature més senzilla són quatre fitxers, dos dels quals són tests. La més complexa, 85. Cadascuna només té les carpetes que realment necessita. Cap directori services/ buit perquè una plantilla deia que hi havia de ser.

Com queden 85 fitxers quan viuen junts

src/features/Auth/
├── __tests__/
├── api/
│   └── __tests__/
├── components/
│   ├── __tests__/
│   ├── PINDot.tsx
│   ├── PINDot.stories.tsx
│   ├── PINInput.tsx
│   ├── PINInput.stories.tsx
│   ├── PINKeypad.tsx
│   └── PINKeypad.stories.tsx
├── context/
│   └── AuthContext.tsx
├── hooks/
│   └── useAuth.ts
├── services/
│   └── pinLockoutService.ts
├── store/
│   ├── __tests__/
│   ├── actions.ts
│   ├── index.ts
│   ├── reducer.ts
│   └── selectors.ts
├── utils/
│   ├── __tests__/
│   ├── emailResendRateLimiter.ts
│   ├── pinHashing.ts
│   ├── pinValidation.ts
│   └── rateLimiter.ts
├── validation/
│   ├── __tests__/
│   ├── customRules.ts
│   ├── loginSchema.ts
│   ├── passwordRecoverySchema.ts
│   └── registrationSchema.ts
├── EmailVerificationScreen.tsx
├── ForgotPasswordScreen.tsx
├── LoginScreen.tsx
├── PINSetupScreen.tsx
├── RegistrationScreen.tsx
├── ResetPasswordScreen.tsx
└── index.ts

(Abreujat: els barrels index.ts de cada carpeta, dues carpetes auxiliars sota validation/ i alguns esquemes germans s’ometen; l’arbre complet és al repo.)

El hashing del PIN és al costat de la validació del PIN, al costat dels components de PIN, al costat de la pantalla de configuració del PIN. La relació entre fitxers es veu a la mateixa disposició de carpetes. Obro Auth/ i puc veure cada peça del sistema d’autenticació sense anar enlloc més.

En una estructura per tipus, aquests mateixos fitxers de PIN estarien a components/, utils/, services/ i screens/. Quatre carpetes per un sol concepte.

La prova d’eliminació en la pràctica

Com queda de debò per a cada disposició?

Per tipus: esborra fitxers de screens/, components/, hooks/, services/, store/, utils/, validation/ i __tests__/. Si et deixes un fitxer, tens un orfe. Si et deixes un import, l’app peta a l’arrencada.

Per feature: esborra src/features/Auth/, treu authReducer de la configuració de l’store, treu les rutes de navegació. Tres passos. El compilador m’avisa si m’he deixat alguna referència.

Ho he fet. Eliminar una feature que tocava més de 40 fitxers va trigar menys d’un minut. La major part d’aquell minut va ser la configuració de navegació.

El contracte que fa segur el refactoring

Cada feature només exporta el que la resta de l’app necessita. L’index.ts a l’arrel de la feature és el contracte:

// src/features/Auth/index.ts
export { authReducer, login, logout, selectIsAuthenticated } from './store';
export { AuthProvider } from './context';
export { useAuth } from './hooks';
export { LoginScreen } from './LoginScreen';
export { RegistrationScreen } from './RegistrationScreen';

El hashing del PIN, la limitació de peticions, la lògica de bloqueig. Res de fora d’Auth no n’importa res. La configuració de l’store pren authReducer, el barrel de l’store reexporta les actions i els selectors d’auth, la navegació pren les pantalles, i Settings, Profile i ProtectedRoute prenen useAuth, uns quants selectors i un esquema de validació. Aquesta és tota la superfície de la qual depèn la resta de l’app, així que puc reescriure tota la implementació del PIN sense que res d’això se n’assabenti.

Mantenir l’índex així d’estret demana manteniment. El meu s’ha anat eixamplant més enllà d’aquell bloc de contracte, reexportant els helpers del PIN i els rate limiters que cap consumidor no ha demanat mai. Cadascuna d’aquestes línies és una garantia regalada a canvi de res, així que esporga l’índex quan creixi, no quan alguna cosa es trenqui.

Fronteres entre features

Aquesta és la regla de la qual depèn la resta: les parts internes de cada feature són privades.

Si Auth necessita saber si un perfil està carregat, llegeix l’store de Redux a través d’un selector en lloc de ficar-se als fitxers de Profile. Quan una feature necessita de debò codi d’una altra, com quan Settings crida useAuth, passa per l’índex públic d’aquella feature, mai per les seves parts internes. L’estat compartit circula per l’store, el contracte estret per l’índex, i res més no creua la frontera.

Cada feature gestiona el seu propi slice de Redux. L’store arrel els combina:

// Els reducers venen del submòdul store de cada feature, no del seu barrel
// públic. Els barrels també exporten pantalles, i aquestes pantalles importen
// l'store, així que importar un barrel aquí tanca un cicle de require: el
// reducer encara és undefined quan s'executa combineReducers, i
// Redux descarta l'slice en silenci.
import { authReducer } from '@app/features/Auth/store';
import { profileReducer } from '@app/features/Profile/store';
import { settingsReducer } from '@app/features/Settings/store';
import { educationReducer } from '@app/features/Education/store';
import { workExperienceReducer } from '@app/features/WorkExperience/store';

const rootReducer = combineReducers({
  settings: settingsReducer,
  auth: persistedAuthReducer,
  profile: profileReducer,
  workExperience: workExperienceReducer,
  education: educationReducer,
});

Deixa que les features es fiquin a les parts internes de les altres i les dependències circulars arriben de seguida. La feature A importa de la B, que importa de la C, que importa de la A. El bundler llança un error críptic i ningú no sap on comença el cicle.

El codi compartit es guanya el seu lloc

Si un component el fa servir una sola feature, es queda dins d’aquella feature. Si dues o més el necessiten, es mou a src/shared/. El llistó és alt.

Cada abstracció compartida és un punt d’acoblament. En el moment que AlertBox viu a shared/, cinc features depenen de la seva interfície. Canviar-lo vol dir revisar les cinc. Prefereixo duplicar tres línies en dues features abans que crear una utilitat compartida que faci les dues més difícils de canviar pel seu compte.

Els hooks que acaben a shared/ són els genuïnament transversals: useAppColorScheme, useHapticFeedback, useReducedMotion, useCameraPermission, usePhotoLibraryPermission. Coses que qualsevol pantalla pot necessitar. No coses que dues pantalles resulta que necessiten ara mateix.

Els tests segueixen la mateixa regla

Els tests viuen al costat del codi que proven. Els tests de l’store d’Auth són a Auth/store/__tests__/. Els tests de validació d’Auth són a Auth/validation/__tests__/. Cap arbre de tests separat a l’arrel del projecte.

Dues excepcions se situen per sobre de les features. Els tests d’integració entre features (el login que flueix cap a la càrrega del perfil, canvis de configuració que es propaguen a la UI, tasques en segon pla entre features) viuen a src/features/__tests__/, fora de cap feature individual. Els recorreguts de tota l’app que exerciten el shell sencer (cicle de vida, orientació del dispositiu, pressió de memòria, notificacions push) viuen encara un nivell més amunt, a src/__tests__/.

src/features/__tests__/
├── BackgroundTasks.integration.rntl.tsx
├── CrossFeatureIntegration.rntl.tsx
├── OnboardingJourney.integration.rntl.tsx
├── ProfileCompletionJourney.integration.rntl.tsx
└── RealtimeSubscription.integration.rntl.tsx

Quan un test falla, la ubicació em diu on mirar. Si és a Auth/store/__tests__/, el problema és a l’store d’auth. Si és a features/__tests__/, el problema és en com interactuen les features. Si és a src/__tests__/, el problema és de tota l’app. La ubicació és el diagnòstic.

Quan canviar

Si la teva app té tres pantalles i cap gestió d’estat, no facis això. Una llista plana de pantalles i un parell de hooks compartits ja van bé. L’estructura per feature afegeix sobrecàrrega que els projectes petits no necessiten.

El punt d’inflexió cau al voltant de cinc features amb el seu propi estat. Per sobre, l’estructura per tipus es converteix en allò que et frena.

Obre la teva carpeta screens/ ara mateix. Compta els fitxers. Si no pots dir quins van junts només mirant la llista, l’estructura ja ha deixat d’ajudar-te.

Posar-ho en marxa

Aquesta estructura és una convenció, no una eina. Dues peces de configuració l’aguanten.

Path aliases. Sense ells, acabes amb import { authReducer } from '../../../features/Auth' a tot arreu. Afegeix els aliases a tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app": ["src"],
      "@app/*": ["src/*"]
    }
  }
}

I a babel.config.js perquè el runtime els resolgui:

module.exports = {
  presets: ['@react-native/babel-preset'],
  plugins: [
    [
      'module-resolver',
      {
        root: ['./src'],
        alias: {
          '@app': './src',
        },
      },
    ],
  ],
};
yarn add -D babel-plugin-module-resolver@5.0.3

Ara import { authReducer } from '@app/features/Auth' es resol en temps de compilació i en runtime, sigui on sigui el fitxer que l’importa.

Una regla d’ESLint per mantenir la frontera honesta. Els path aliases sols no impedeixen que algú escrigui import { profileSelector } from '@app/features/Profile' dins d’Auth. En el moment que això es publica, l’estructura comença a esfondrar-se. Una regla no-restricted-imports fixa la frontera:

// eslint.config.mjs
export default [
  {
    rules: {
      'no-restricted-imports': ['error', {
        patterns: [
          {
            group: ['@app/features/*/*', '@app/features/*/*/**'],
            message: 'Import another feature through its public index (@app/features/X), not its internals. Within a feature, use relative imports.',
          },
        ],
      }],
    },
  },
  {
    // Els tests poden accedir a les parts internes d'una feature per preparar l'estat.
    files: ['**/__tests__/**'],
    rules: { 'no-restricted-imports': 'off' },
  },
  {
    // L'store arrel només cableja reducers, i els importa del submòdul store
    // de cada feature per evitar el cicle de require del barrel.
    files: ['src/store/configureStore.ts'],
    rules: { 'no-restricted-imports': 'off' },
  },
];

El primer patró bloqueja qualsevol cosa un nivell dins d’una feature (@app/features/Auth/store); el segon, qualsevol cosa més profunda; l’import de l’índex a seques @app/features/Auth no coincideix amb cap dels dos, així que la superfície pública queda oberta. Una trampa que val la pena conèixer: aquests patrons usen coincidència a l’estil gitignore, no extglob, així que una exclusió temptadora amb !(index) no coincideix amb res, en silenci. Dins d’una feature fas servir imports relatius (./store, ../components), que no coincideixen mai amb el patró d’àlies, així que una feature sempre pot accedir al seu propi codi. Dues exempcions: els tests, que sovint necessiten entrar en una feature per preparar l’estat, i la configuració de l’store arrel, que importa el submòdul store de cada feature per quedar-se fora del cicle de require del barrel.

Res més. Path aliases, una regla d’ESLint, i la disciplina de mantenir privades les parts internes de cada feature.

El codi font complet del projecte és a github.com/warrendeleon/rn-warrendeleon; el tag blog-2026-08 marca l’estat exacte que aquest post descriu.

Warren de Leon
Warren de Leon

Software Engineering Manager. Recentment he liderat l'equip de Mobile Platform a Hargreaves Lansdown. Escric sobre lideratge tècnic, React Native i com construir bons equips.

Veure perfil