Estructura de proyecto feature-first en React Native

Por qué uso una estructura de proyecto feature-first en React Native

Versión corta: por debajo de unas cinco features con su propio estado, las carpetas type-first (screens/, hooks/, services/) están bien. Por encima de eso, el mismo layout empieza a costar más de lo que ahorra. Este post va del porqué, y de dónde está la frontera.

85 archivos para una sola feature

Esos son los archivos TypeScript que tiene mi feature de Auth. Seis pantallas, un store de Redux, un contexto de React, un custom hook, componentes de PIN con stories de Storybook, esquemas de validación de formularios contra una blacklist de contraseñas comunes, rate limiting, un servicio de lockout, y tests a todos los niveles.

En la mayoría de proyectos React Native, esos 85 archivos estarían repartidos entre siete carpetas diferentes. Las pantallas en un sitio, los hooks en otro, el store slice en otro, la validación en otro más. Para entender cómo funciona la autenticación, abrirías siete carpetas y reconstruirías mentalmente las relaciones entre archivos que no están cerca unos de otros.

Ese layout queda ordenado con tres o cuatro pantallas. Pasado ese punto, las relaciones se vuelven invisibles. El hook de una feature vive lejos de la pantalla que lo usa. Las reglas de validación están en una carpeta separada del formulario que validan. Revisar una feature implica escanear varias listas alfabéticas buscando las piezas.

El layout type-first, y por qué es la opción por defecto

Este ya lo conoces:

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

Archivos agrupados por tipo. Type-first. La mayoría de tutoriales de React Native organizan las cosas así, y hay buenas razones para ello. Quienes llegan nuevos al proyecto reconocen la forma al instante. Un revisor que ojea una prueba técnica detecta screens/, hooks/, components/ sin pensar. Los nombres de carpeta encajan con el vocabulario del framework, así que el modelo mental se traslada de un proyecto a otro. Para tres o cuatro pantallas, eso es estructura suficiente para mantener el orden. Si alguna vez has hecho una prueba técnica, la estructura de carpetas es una de las primeras cosas que mira un revisor, y type-first es la apuesta segura ahí.

La forma aguanta mientras la app es pequeña. Luego añades autenticación con configuración de PIN, verificación de email, recuperación de contraseña. Añades gestión de perfil con subida de fotos, edición de cuenta, cambio de contraseña. De repente screens/ tiene 25 archivos, y encontrar el hook que pertenece a la subida de foto de perfil implica escanear una lista alfabética de todos los hooks de la app.

Ahora intenta eliminar una feature. Borra la pantalla de screens/. Busca su hook en hooks/. Su servicio en services/. Su store slice. Sus componentes. Su esquema de validación. Sus tests, en un árbol __tests__/ separado. Si te dejas un archivo, tienes código muerto que va a quedarse ahí meses.

Esa es la prueba. Si eliminar una feature lleva más tiempo que construirla, la estructura está jugando en tu contra.

Una carpeta por feature

Mi app tiene 13 features. Cada una vive en un solo directorio:

src/features/
├── Auth/           # 85 archivos. Login, registro, PIN, lockout
├── Profile/        # API, store, subida de foto, 5 pantallas
├── Settings/       # Tema, idioma, 3 pantallas
├── Education/      # Store, API, 1 pantalla
├── WorkExperience/ # Store, API, 4 pantallas
├── Home/           # 1 pantalla, 1 export
├── Legal/          # Política de privacidad, términos y condiciones
├── Permissions/    # Cámara, fototeca, pantallas de denegación
├── MockStatus/     # Pantalla de estado MSW solo para dev
├── PDF/            # Visor de PDF
├── Placeholder/    # Placeholders de chat, reservas
├── WebView/        # Pantalla webview genérica
└── Splash/         # Splash screen

Todo lo demás vive fuera de features: shared/ para componentes y hooks reutilizables, store/ para la configuración de Redux, navigation/, httpClients/, utils/, i18n/.

La feature más simple tiene cuatro archivos, dos de ellos tests. La más compleja, 85. Cada una solo tiene las carpetas que realmente necesita. Ningún directorio services/ vacío porque una plantilla decía que debería estar ahí.

Qué aspecto tienen 85 archivos cuando viven juntos

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

(Abreviado: los barrels index.ts de cada carpeta, dos carpetas auxiliares bajo validation/ y algunos esquemas hermanos se omiten; el árbol completo está en el repo.)

El hashing del PIN se sitúa al lado de la validación del PIN, al lado de los componentes del PIN, al lado de la pantalla de configuración del PIN. La relación entre archivos es visible en la propia estructura de carpetas. Abro Auth/ y puedo ver cada pieza del sistema de autenticación sin ir a ningún otro sitio.

En una estructura type-first, esos mismos archivos del PIN estarían en components/, utils/, services/ y screens/. Cuatro carpetas para un solo concepto.

La prueba de eliminar en la práctica

¿Qué aspecto tiene en realidad cada layout?

Type-first: borrar archivos de screens/, components/, hooks/, services/, store/, utils/, validation/ y __tests__/. Si te dejas un archivo, tienes un huérfano. Si te dejas un import, la app falla al arrancar.

Feature-first: borrar src/features/Auth/, quitar authReducer de la configuración del store, eliminar las rutas de navegación. Tres pasos. El compilador me dice si me dejé alguna referencia.

Lo he hecho. Eliminar una feature que tocaba más de 40 archivos me llevó menos de un minuto. La mayor parte de ese minuto fue la configuración de navegación.

El contrato que hace seguro refactorizar

Cada feature exporta solo lo que el resto de la app necesita. El index.ts en la raíz de la feature es el contrato:

// 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, el rate limiting, la lógica de lockout. Nada de esto se importa desde fuera de Auth. La configuración del store toma authReducer, el barrel del store reexporta las actions y los selectors de auth, la navegación toma las pantallas, y Settings, Profile y ProtectedRoute toman useAuth, unos cuantos selectors y un esquema de validación. Esa es toda la superficie de la que depende el resto de la app, así que puedo reescribir la implementación entera del PIN sin que nada de eso se entere.

Mantener el index así de estrecho requiere mantenimiento. El mío se ha ido ensanchando más allá de ese bloque de contrato, reexportando los helpers del PIN y los rate limiters que ningún consumidor ha pedido nunca. Cada una de esas líneas es una garantía regalada a cambio de nada, así que poda el index cuando crezca, no cuando algo se rompa.

Fronteras entre features

Esta es la regla de la que depende el resto: las partes internas de cada feature son privadas.

Si Auth necesita saber si un perfil está cargado, lee el store de Redux a través de un selector en vez de meterse en los archivos de Profile. Cuando una feature necesita de verdad código de otra, como cuando Settings llama a useAuth, pasa por el index público de esa feature, nunca por sus partes internas. El estado compartido va por el store, el contrato estrecho va por el index, y nada más cruza la frontera.

Cada feature mantiene su propio slice de Redux. El root store los combina:

// Los reducers vienen del submódulo store de cada feature, no de su barrel
// público. Los barrels también exportan pantallas, y esas pantallas importan
// el store, así que importar un barrel aquí cierra un ciclo de require: el
// reducer sigue siendo undefined en el momento en que se ejecuta combineReducers,
// y Redux descarta el slice en silencio.
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,
});

Deja que las features se metan en las partes internas de otras y las dependencias circulares llegan enseguida. La Feature A importa de la Feature B, que importa de la Feature C, que importa de la Feature A. El bundler lanza un error críptico y nadie sabe dónde empieza el ciclo.

El código compartido se gana su sitio

Si un componente lo usa una sola feature, se queda en esa feature. Si dos o más features lo necesitan, se mueve a src/shared/. El listón es alto.

Cada abstracción compartida es un punto de acoplamiento. En el momento en que AlertBox vive en shared/, cinco features dependen de su interfaz. Cambiarlo implica revisar las cinco. Prefiero duplicar tres líneas en dos features antes que crear una utilidad compartida que haga a ambas más difíciles de cambiar por separado.

Los hooks que acaban en shared/ son los genuinamente transversales: useAppColorScheme, useHapticFeedback, useReducedMotion, useCameraPermission, usePhotoLibraryPermission. Cosas que cualquier pantalla puede necesitar. No cosas que resulta que necesitan dos pantallas ahora mismo.

Los tests siguen la misma regla

Los tests viven al lado del código que testean. Los tests del store de Auth están en Auth/store/__tests__/. Los tests de validación de Auth están en Auth/validation/__tests__/. No hay un árbol de tests separado en la raíz del proyecto.

Dos excepciones se sitúan por encima de las features. Los tests de integración entre features (el login que desemboca en la carga del perfil, cambios en Settings que se propagan a la UI, tareas en segundo plano que se ejecutan entre features) viven en src/features/__tests__/, fuera de cualquier feature individual. Los recorridos de toda la app que ejercitan el shell completo (ciclo de vida, orientación del dispositivo, presión de memoria, notificaciones push) viven un nivel más arriba todavía, en src/__tests__/.

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

Cuando un test falla, la ubicación me dice dónde mirar. Si está en Auth/store/__tests__/, el problema está en el store de auth. Si está en features/__tests__/, el problema está en cómo interactúan las features. Si está en src/__tests__/, el problema es de toda la app. La ubicación es el diagnóstico.

Cuándo hacer el cambio

Si tu app tiene tres pantallas y ninguna gestión de estado, no hagas esto. Una lista plana de pantallas y un par de hooks compartidos es suficiente. Feature-first añade una sobrecarga que los proyectos pequeños no necesitan.

El punto de inflexión está en torno a las cinco features con su propio estado. Por encima, type-first se convierte en lo que te frena.

Abre tu carpeta screens/ ahora mismo. Cuenta los archivos. Si no sabes decir cuáles van juntos solo mirando la lista, la estructura ya ha dejado de ayudarte.

Cómo montarlo

Esta estructura es una convención, no una herramienta. Dos piezas de configuración hacen que se mantenga.

Path aliases. Sin ellos, acabas con import { authReducer } from '../../../features/Auth' por todas partes. Añade los aliases en tsconfig.json:

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

Y en babel.config.js para que el runtime los resuelva:

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

Ahora import { authReducer } from '@app/features/Auth' se resuelve en tiempo de compilación y en runtime, sin importar dónde esté el archivo que lo importa.

Una regla de ESLint para hacer que el límite se respete. Los path aliases por sí solos no impiden que alguien escriba import { profileSelector } from '@app/features/Profile' dentro de Auth. En cuanto eso se publica, la estructura empieza a desmoronarse. Una regla no-restricted-imports fija el límite:

// 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.',
          },
        ],
      }],
    },
  },
  {
    // Los tests pueden acceder a las partes internas de una feature para preparar estado.
    files: ['**/__tests__/**'],
    rules: { 'no-restricted-imports': 'off' },
  },
  {
    // El root store solo conecta reducers, y los importa del submódulo store
    // de cada feature para evitar el ciclo de require del barrel.
    files: ['src/store/configureStore.ts'],
    rules: { 'no-restricted-imports': 'off' },
  },
];

El primer patrón bloquea cualquier cosa que esté un nivel por dentro de una feature (@app/features/Auth/store); el segundo, cualquier cosa más profunda. El import del index a secas, @app/features/Auth, no coincide con ninguno de los dos, así que la superficie pública queda abierta. Una trampa que conviene conocer: estos patrones usan matching estilo gitignore, no extglob, así que una exclusión tentadora como !(index) no coincide con nada y falla en silencio. Dentro de una feature usas imports relativos (./store, ../components), que nunca coinciden con el patrón del alias, así que una feature siempre puede acceder a su propio código. Dos exenciones: los tests, que a menudo necesitan acceder al interior de una feature para preparar estado, y la configuración del root store, que importa el submódulo store de cada feature para quedarse fuera del ciclo de require del barrel.

Eso es todo. Path aliases, una regla de ESLint, y la disciplina de mantener privadas las partes internas de cada feature.

El código fuente completo del proyecto está en github.com/warrendeleon/rn-warrendeleon; el tag blog-2026-08 marca el estado exacto que este post describe.

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil