DEV Community

Cover image for Why I use feature-first project structure in React Native
Warren de Leon
Warren de Leon

Posted on Edited on Originally published at warrendeleon.com

Why I use feature-first project structure in React Native

📚 React Native Foundations series — read it in full on warrendeleon.com, where new parts land first.

The short version: below roughly five features with their own state, type-first folders (screens/, hooks/, services/) are fine. Above that, the same layout starts costing you more than it saves. This post is about why, and where the line sits.

85 files for one feature

That's how many TypeScript files my Auth feature has. Six screens, a Redux store, a React context, a custom hook, PIN components with Storybook stories, form validation schemas against a common password blacklist, rate limiting, a lockout service, and tests at every level.

In most React Native projects, those 85 files would be scattered across seven different folders. Screens in one place, hooks in another, the store slice somewhere else, validation in yet another. To understand how authentication works, you'd open seven folders and mentally reconstruct the relationships between files that sit far apart in the tree.

That layout looks tidy at three or four screens. Past that, the relationships go invisible. The hook for a feature lives far from the screen that uses it. The validation rules sit in a separate folder from the form they validate. Reviewing a feature means scanning multiple alphabetised lists looking for the pieces.

The type-first layout, and why it's the default

You know this one:

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
Enter fullscreen mode Exit fullscreen mode

Files grouped by kind. Type-first. Most React Native tutorials lay things out this way, and there are good reasons for it. New contributors recognise the shape instantly. A reviewer skimming a take-home test can spot screens/, hooks/, components/ without thinking. The folder names map onto the framework's vocabulary, so the mental model carries from one project to the next. For three or four screens, that's enough structure to keep things in order. If you've ever done a take-home tech test, your folder structure is one of the first things a reviewer looks at, and type-first is the safe pick there.

The shape holds while the app is small. Then you add authentication with PIN setup, email verification, password recovery. You add profile management with picture uploads, account editing, password changes. Suddenly screens/ has 25 files, and finding the hook that belongs to the profile picture upload means scanning an alphabetical list of every hook in the app.

Now try to delete a feature. Remove the screen from screens/. Find its hook in hooks/. Its service in services/. Its store slice. Its components. Its validation schema. Its tests, sitting in a separate __tests__/ tree. Miss one file and you've got dead code that'll sit there for months.

That's the test. If removing a feature takes longer than building one, the structure is working against you.

One folder per feature

My app has 13 features. Each lives in a single directory:

src/features/
├── Auth/           # 85 files. Login, registration, PIN, lockout
├── Profile/        # API, store, picture upload, 5 screens
├── Settings/       # Theme, language, 3 screens
├── Education/      # Store, API, 1 screen
├── WorkExperience/ # Store, API, 4 screens
├── Home/           # 1 screen, 1 export
├── Legal/          # Privacy policy, T&Cs
├── Permissions/    # Camera, photo library, denial screens
├── MockStatus/     # Dev-only MSW status screen
├── PDF/            # PDF viewer
├── Placeholder/    # Chat, booking placeholders
├── WebView/        # Generic webview screen
└── Splash/         # Splash screen
Enter fullscreen mode Exit fullscreen mode

Everything else sits outside features: shared/ for reusable components and hooks, store/ for the Redux config, navigation/, httpClients/, utils/, i18n/.

The simplest feature is four files, two of them tests. The most complex is 85. Each one only has the folders it actually needs. No empty services/ directory because a template said it should be there.

What 85 files look like when they're co-located

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
Enter fullscreen mode Exit fullscreen mode

(Abridged: the per-folder index.ts barrels, two helper folders under validation/ and a few sibling schemas are elided; the full tree is in the repo.)

PIN hashing sits next to PIN validation, next to the PIN components, next to the PIN setup screen. The relationship between files is visible in the folder layout. I open Auth/ and I can see every piece of the authentication system without going anywhere else.

In a type-first structure, those same PIN files would be in components/, utils/, services/, and screens/. Four folders for one concept.

The delete test in practice

What does it actually look like for each layout?

Type-first: delete files from screens/, components/, hooks/, services/, store/, utils/, validation/, and __tests__/. Miss a file and you've got an orphan. Miss an import and the app crashes at boot.

Feature-first: delete src/features/Auth/, remove authReducer from the store config, remove the navigation routes. Three steps. The compiler tells me if I missed a reference.

I've done this. Removing a feature that touched 40+ files took less than a minute. Most of that minute was the navigation config.

The contract that makes refactoring safe

Every feature exports only what the rest of the app needs. The index.ts at the feature root is the contract:

// 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';
Enter fullscreen mode Exit fullscreen mode

PIN hashing, rate limiting, lockout logic. Nothing outside Auth imports any of it. The store config takes authReducer, the store barrel re-exports the auth actions and selectors, navigation takes the screens, and Settings, Profile and ProtectedRoute take useAuth, a few selectors and one validation schema. That is the whole surface the rest of the app leans on, so I can rewrite the entire PIN implementation without any of it noticing.

Keeping the index that narrow takes maintenance. Mine has drifted wider than that contract block, re-exporting the PIN helpers and the rate limiters that no consumer has ever asked for. Every one of those lines is a guarantee given away for nothing, so prune the index when it grows rather than when something breaks.

Boundaries between features

This is the rule the rest depends on: feature internals stay private.

If Auth needs to know whether a profile is loaded, it reads the Redux store through a selector rather than reaching into Profile's files. When one feature genuinely needs another's code, the way Settings calls useAuth, it goes through that feature's public index, never its internals. The store carries shared state, the index carries the narrow contract, and nothing else crosses the boundary.

📊 Diagram: view it on warrendeleon.com

Each feature owns its Redux slice. The root store combines them:

// Reducers come from each feature's store submodule, not its public barrel.
// The barrels also export screens, and those screens import the store, so
// importing a barrel here closes a require cycle: the reducer is still
// undefined at the moment combineReducers runs, and Redux silently drops
// the slice.
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,
});
Enter fullscreen mode Exit fullscreen mode

Let features reach into each other's internals and circular dependencies follow quickly. Feature A imports from Feature B, which imports from Feature C, which imports from Feature A. The bundler throws a cryptic error and nobody knows where the cycle starts.

Shared code earns its place

If a component is used by one feature, it stays in that feature. If two or more features need it, it moves to src/shared/. The bar is high.

Every shared abstraction is a coupling point. The moment AlertBox lives in shared/, five features depend on its interface. Changing it means checking all five. I'd rather duplicate three lines in two features than create a shared utility that makes both harder to change on their own.

The hooks that end up in shared/ are the genuinely cross-cutting ones: useAppColorScheme, useHapticFeedback, useReducedMotion, useCameraPermission, usePhotoLibraryPermission. Things any screen might need. Not things that two screens happen to need right now.

Tests follow the same rule

Tests live next to the code they test. Auth store tests are in Auth/store/__tests__/. Auth validation tests are in Auth/validation/__tests__/. No separate test tree at the project root.

Two exceptions sit above the features. Cross-feature integration tests (login flowing into profile loading, settings changes propagating to the UI, background tasks running across features) live in src/features/__tests__/, outside any single feature. App-wide journeys that exercise the whole shell (lifecycle, device orientation, memory pressure, push notifications) live one level up again, in src/__tests__/.

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

When a test breaks, the location tells me where to look. If it's in Auth/store/__tests__/, the problem is in the auth store. If it's in features/__tests__/, the problem is in how features interact. If it's in src/__tests__/, the problem is app-wide. The location is the diagnosis.

When to switch

If your app has three screens and no state management, don't do this. A flat list of screens and a couple of shared hooks is fine. Feature-first adds overhead that small projects don't need.

The crossover sits around five features with their own state. Above that, type-first becomes the thing slowing you down.

Open your screens/ folder right now. Count the files. If you can't tell which ones belong together just by looking at the list, the structure has already stopped helping you.

Setting it up

This structure is a convention, not a tool. Two pieces of config make it stick.

Path aliases. Without them, you end up with import { authReducer } from '../../../features/Auth' everywhere. Add aliases in tsconfig.json:

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

And in babel.config.js so the runtime resolves them:

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

Now import { authReducer } from '@app/features/Auth' resolves at compile time and runtime, regardless of where the importing file sits.

An ESLint rule to keep the boundary honest. Path aliases alone won't stop someone from writing import { profileSelector } from '@app/features/Profile' inside Auth. Once that ships, the boundary is gone. A no-restricted-imports rule pins the boundary:

// 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.',
          },
        ],
      }],
    },
  },
  {
    // Tests can reach into a feature's internals to set up state.
    files: ['**/__tests__/**'],
    rules: { 'no-restricted-imports': 'off' },
  },
  {
    // The root store wires reducers only, and imports them from each
    // feature's store submodule to avoid the barrel require cycle.
    files: ['src/store/configureStore.ts'],
    rules: { 'no-restricted-imports': 'off' },
  },
];
Enter fullscreen mode Exit fullscreen mode

The first pattern blocks anything one level inside a feature (@app/features/Auth/store), the second anything deeper; the bare @app/features/Auth index import matches neither, so the public surface stays open. One trap worth knowing: these patterns use gitignore-style matching, not extglob, so a tempting !(index) exclusion silently matches nothing. Within a feature you use relative imports (./store, ../components), which never match the alias pattern, so a feature can always reach its own code. Two exemptions: tests, which often need to reach inside a feature to set up state, and the root store config, which imports each feature's store submodule to stay out of the barrel require cycle.

That's it. Path aliases, one ESLint rule, and the discipline to keep each feature's internals private.

The full project source is at github.com/warrendeleon/rn-warrendeleon; the blog-2026-08 tag marks the exact state this post describes.

Top comments (0)