Kunal Jain

Feature-Driven Architecture in a React Application

May 19, 2026

A case study on how we organised 17 feature modules into a predictable, maintainable, five-layer pattern - and why it worked.


1. The Problem

Every non-trivial web application starts simple. You have a few screens, a handful of API calls, and the folder structure just feels right. Then the business asks for more features. And more. And more.

Six months in, you have:

  • API calls scattered across component files
  • Types defined inline or not at all
  • Three different ways of fetching data (one per developer)
  • Forms that blur validation, submission, and rendering logic
  • A components/ directory with 200 files and no meaningful grouping
  • PRs where it's impossible to tell what touches what

We hit this wall on a Next.js finance platform. The application handles loan origination, disbursement, collections, accounting, reporting, CRM, HRMS, and a dozen other domains. Without structure, this was unsustainable.

This is the pattern we built to survive.


2. The Pattern at a Glance

Every discrete domain in the application - every "feature" - gets its own directory under src/features/<feature-name>/. Inside that directory, the code is organised into five layers, each with a single responsibility and a strict dependency direction:

src/features/<name>/
├── types.ts       # 1. Data shapes
├── api.ts         # 2. HTTP calls
├── queries.ts     # 3. Query key definitions
├── hooks.ts       # 4. React hooks (queries + mutations)
├── schema.ts      # 5. Validation schemas (optional)
└── components/    # 6. UI components

The dependency flow is one-way:

types.ts  api.ts  queries.ts  hooks.ts  components/
                                             pages/

Each layer imports only from layers below it (or from shared libraries). Components never import from api.ts directly. Pages never write their own data fetching logic.


3. Layer 1: Types (types.ts)

This is the foundation. Every entity the feature works with - API responses, request bodies, computed display models - gets an explicit interface.

// types.ts

export interface IProduct {
  id: string
  name: string
  categoryId: string
  price: number
  status: "ACTIVE" | "INACTIVE" | "DISCONTINUED"
  createdAt: string
}

export interface IProductBody {
  name: string
  categoryId: string
  price: number
  status: "ACTIVE" | "INACTIVE"
}

export interface IProductCategory {
  id: string
  name: string
  parentId: string | null
}

Key rules:

  • Response types vs body types - What the API returns (IProduct) is often different from what it expects (IProductBody). Keeping them separate prevents coupling between the API shape and the form shape.
  • Colocation - Types live with the feature that owns them. If two features share a type, it goes into src/types/ with a clear naming prefix.

4. Layer 2: API (api.ts)

This layer is a thin wrapper around our HTTP client (Axios). Every function takes typed parameters, calls one endpoint, and returns the raw response.

// api.ts

import { makeRequest } from "@/lib/http"
import type { AxiosRequestConfig } from "axios"
import type { IProduct, IProductBody } from "./types"

interface IResponseType<T> {
  data: T
  message?: string
}

export interface IGetProductFilters {
  id: string
  categoryId: string
  status: "ACTIVE" | "INACTIVE" | "DISCONTINUED"
}

export function getProducts(
  options?: Partial<IGetProductFilters>,
  axiosRequestConfig?: AxiosRequestConfig,
) {
  return makeRequest.get<IResponseType<IProduct[]>>("/catalog/products", {
    ...axiosRequestConfig,
    params: {
      ...axiosRequestConfig?.params,
      ...options,
    },
  })
}

export function getProductById(id: string) {
  return makeRequest.get<IResponseType<IProduct>>(`/catalog/products/${id}`)
}

export function createProduct(body: IProductBody) {
  return makeRequest.post<IResponseType<IProduct>>("/catalog/products", body)
}

export function updateProduct(id: string, body: Partial<IProductBody>) {
  return makeRequest.put<IResponseType<IProduct>>(`/catalog/products/${id}`, body)
}

export function deleteProduct(id: string) {
  return makeRequest.delete(`/catalog/products/${id}`)
}

Why this layer exists:

  • Testability - API functions can be mocked at the boundary without mocking Axios or intercepting network calls.
  • Centralised configuration - Every request goes through the same Axios instance with interceptors for auth tokens, error normalisation, and serialisation.
  • Forwarding request config - Accepting AxiosRequestConfig lets callers override headers, cancel tokens, or timeout on individual calls.

5. Layer 3: Queries (queries.ts)

This is the bridge between raw API functions and React's data lifecycle. We use a query key factory to define every server state our feature cares about.

// queries.ts

import { createQueryKeys } from "@lukemorales/query-key-factory"
import { getProducts, getProductById, type IGetProductFilters } from "./api"

export const productQueries = createQueryKeys("products", {
  list: (filters?: Partial<IGetProductFilters>) => ({
    queryKey: [filters],
    queryFn: async () => {
      const res = await getProducts(filters)
      return res.data.data
    },
  }),
  detail: (id: string) => ({
    queryKey: [id],
    queryFn: async () => {
      const res = await getProductById(id)
      return res.data.data
    },
  }),
})

Why queries are separate from hooks:

  • Reusability - Query definitions can be composed, extended, or invalidated without needing a component context. They are plain objects.
  • Type safety in query keys - The factory generates typed keys. You cannot accidentally invalidate the wrong query because the key shape is enforced.
  • One place for data transformation - The queryFn is where we unwrap the response envelope (res.data.data). Components never see the HTTP response structure.

6. Layer 4: Hooks (hooks.ts)

This is the public API of the feature module. Hooks are what components and pages import. They wrap TanStack Query's useQuery and useMutation with feature-specific defaults.

// hooks.ts

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { createProduct, updateProduct, deleteProduct } from "./api"
import { productQueries } from "./queries"
import type { IProductBody } from "./types"

interface IUseProductListOptions {
  enabled?: boolean
  filters?: Partial<Parameters<typeof productQueries.list>[0]>
}

export function useGetProductListQuery({
  enabled = true,
  filters,
}: IUseProductListOptions = {}) {
  return useQuery({
    enabled,
    ...productQueries.list(filters),
  })
}

export function useGetProductDetailQuery(id: string) {
  return useQuery({
    enabled: !!id,
    ...productQueries.detail(id),
  })
}

export function useCreateProductMutation() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (body: IProductBody) => {
      const res = await createProduct(body)
      return res.data
    },
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: productQueries.list().queryKey,
      })
    },
  })
}

export function useDeleteProductMutation(id: string) {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async () => {
      await deleteProduct(id)
    },
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: productQueries.list().queryKey,
      })
    },
  })
}

Conventions:

  • Query hooks are prefixed useGet + noun + Query - useGetProductListQuery, useGetProductDetailQuery. This reads as "use the get-product-list query" and distinguishes them from mutations at a glance.
  • Mutation hooks are prefixed use + verb + noun + Mutation - useCreateProductMutation, useDeleteProductMutation.
  • First argument is always an options object with enabled and filters - This provides a consistent API. Default enabled is true.
  • Mutations invalidate on success - The mutation hook owns the invalidation logic. Components that call the mutation don't need to know what to refetch.
  • No useQueryClient() in components - It's encapsulated in the hook.

Composing multiple APIs in one hook

Some screens need data from several endpoints before they can render. The hook layer handles this trivially - just compose multiple queries:

// hooks.ts

import { useQuery } from "@tanstack/react-query"
import { productQueries } from "./queries"
import { categoryQueries } from "../categories/queries"

interface IUseProductFormInitialData {
  productId: string
}

export function useProductFormInitialData({
  productId,
}: IUseProductFormInitialData) {
  const product = useQuery({
    enabled: !!productId,
    ...productQueries.detail(productId),
  })

  const categories = useQuery({
    ...categoryQueries.list(),
  })

  return {
    product: product.data,
    categories: categories.data,
    isLoading: product.isLoading || categories.isLoading,
    isError: product.isError || categories.isError,
  }
}

The component calling useProductFormInitialData gets a single return value and doesn't need to know about the underlying API boundaries. This pattern also makes it easy to add preloading, caching, or dependent queries without touching the component.


7. Layer 5: Schema (schema.ts) - Optional

For features with forms, we define validation schemas here instead of inlining them in components.

// schema.ts

import { z } from "zod"

export const productFormSchema = z.object({
  name: z.string().trim().min(1, "Name is required"),
  categoryId: z.string().trim().min(1, "Category is required"),
  price: z.number().min(0, "Price must be positive"),
  status: z.enum(["ACTIVE", "INACTIVE"]),
})

export type ProductFormValues = z.infer<typeof productFormSchema>

Why optional: Simple features without forms don't need a schema.ts. We add it only when forms appear. The schema module also exports the inferred TypeScript type, avoiding duplication between the schema and the interface.


8. Layer 6: Components (components/)

UI components live in the feature directory, organised by purpose. There is no strict sub-directory convention - 2-5 flat files per feature is typical.

// components/product-table.tsx

"use client"

import { useReactTable, getCoreRowModel, ColumnDef } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { useGetProductListQuery } from "../hooks"
import type { IProduct } from "../types"

const columns: ColumnDef<IProduct>[] = [
  {
    id: "name",
    header: "Product",
    accessorKey: "name",
  },
  {
    id: "price",
    header: "Price",
    accessorKey: "price",
    cell: ({ getValue }) => `$${getValue<number>().toFixed(2)}`,
  },
  {
    id: "status",
    header: "Status",
    accessorKey: "status",
    cell: ({ getValue }) => (
      <Badge variant={getValue() === "ACTIVE" ? "success" : "muted"}>
        {getValue<string>()}
      </Badge>
    ),
  },
]

export function ProductTable() {
  const { data: products } = useGetProductListQuery()

  const table = useReactTable({
    data: products ?? [],
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((group) => (
          <TableRow key={group.id}>
            {group.headers.map((header) => (
              <TableHead key={header.id}>
                {flexRender(header.column.columnDef.header, header.getContext())}
              </TableHead>
            ))}
          </TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {table.getRowModel().rows.map((row) => (
          <TableRow key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Component rules:

  • Components import only from ../hooks and ../types - never from ../api.ts or ../queries.ts.
  • Data fetching hooks are called at the top level - no useEffect + setState patterns for server data.
  • No prop drilling deeper than 2 levels - use composition (children, render props) or context.

9. Variations on the Pattern

Not every feature fits the exact same mold. Here are the variations we encountered and how we handled them.

Nested sub-features

Some domains are too large for a single flat directory. Our configurations module has 15 sub-entities (branches, loan products, vendors, dealers, etc.), each deserving its own feature module:

src/features/configurations/
├── branches/
   ├── api.ts
   ├── hooks.ts
   ├── queries.ts
   ├── types.ts
   ├── schema.ts
   └── components/
├── loan-products/
   ├── api.ts
   ├── hooks.ts
   ├── queries.ts
   ├── types.ts
   └── schema.ts
├── vendors/
   └── ... (same pattern)
└── ...

The parent configurations/ directory is just a namespace. It has no index.ts, no shared types - each child is self-contained.

Complex features with sub-domains

The loan application feature is the most complex in the system. It has tabs, receipts, document management, financial calculations, and legal workflows. Its directory is deeper:

src/features/loan-application/
├── index.ts                # Public API (re-exports)
├── types.ts                # Shared types
├── types/                  # Sub-domain type files
├── components/             # 27+ components
├── tabs/                   # Tab-specific components
├── utils/                  # Feature-specific utilities
├── hooks/                  # More hooks than a single file can hold
├── api.ts
├── queries.ts
└── ...

The core five-layer skeleton is still there - types, api, queries, hooks - but the components directory grows richer and sub-directories emerge for organisation.

Barrel exports (index.ts)

We generally avoid barrel exports - they can increase bundle size by preventing tree-shaking and creating circular dependency risks. Components should import directly from the file they need (import { useXQuery } from "../hooks").

That said, barrel exports can still be useful in specific cases:

  • When a feature's public API needs to be consumed across multiple other features (e.g., shared types or hooks)
  • When the feature is a library-style module (e.g., a utility or dropdown data provider)

If you add one, keep it shallow - re-export only what is needed externally, not every symbol in the module.


10. Why It Worked - A Retrospective

What it solved

1. Mental model uniformity. Every developer knows exactly where to put a new API call, a new type, or a new component. There is no deliberation - just follow the pattern. This dramatically reduced decision fatigue and onboarding time.

2. PR clarity. A pull request that touches types.ts, api.ts, and hooks.ts in the same feature is clearly adding an endpoint. One that only touches components/ is a UI change. Reviewers can tell the scope of a change from the file list alone.

3. Fearless refactoring. Want to rename a field across the entire stack? The one-way dependency chain means you start at types.ts and follow the errors. No runtime surprises. Want to swap the data fetching library? Only queries.ts and hooks.ts change - api.ts stays, components/ stays.

4. Separation of concerns without abstraction overkill. There is no BaseApiService or AbstractRepositoryFactory. The pattern has layers but no inheritance, no generics magic, no decorators. Each file is a plain collection of functions or components. Junior developers understand it in an hour.

5. Tree-shaking and code-splitting. Feature directories can be independently loaded. Next.js route segments can lazy-load entire feature bundles because nothing is coupled through a monolithic store or service.

6. Scales across projects, not just within one. This pattern isn't tied to a single codebase. The same structure has been applied to five other projects - a CRM, an HRMS, a fleet management dashboard, an e-commerce admin panel, and a healthcare records system - with minimal adaptation. In every case, the team adopted it within days and reported fewer cross-module bugs. The pattern's portability comes from its simplicity: it's just files in folders with one-way dependencies. No framework, no package, no plugin to maintain.

What it cost

1. Boilerplate. Adding a new feature means creating 5 files before writing any real logic. For very simple features (a dropdown that calls one endpoint), this feels heavy.

2. File count. The project has hundreds of small files. Some developers prefer fewer, larger files. The tradeoff is navigation ease vs. tab overload.

3. Occasional boundary friction. Sometimes a component needs data from two features. Do you create a cross-feature hook? A shared parent component? A page-level orchestrator? The pattern doesn't prescribe the answer - you have to reason about it each time.


11. When to Use This Pattern

This pattern shines when:

  • The application has 10+ distinct business domains (finance, healthcare, logistics, enterprise SaaS)
  • You have multiple teams or contributors who need clear boundaries
  • Your backend is REST-ish with standardised response envelopes
  • Forms and tables are the primary UI pattern (admin panels, dashboards, back-office tools)
  • You expect the app to live for years and need to manage technical debt

It's probably overkill for:

  • A marketing site or content-heavy app
  • An app with fewer than 5 data entities
  • A prototype or MVP where speed of iteration trumps structure

12. In Practice: A Day in the Life

When a ticket says "Add a discount field to the product form," the workflow is:

  1. types.ts - Add discount?: number to IProductBody
  2. schema.ts - Add discount: z.number().min(0).max(100).optional() to the validation
  3. api.ts - Nothing changes (the endpoint already accepts the field)
  4. components/product-form.tsx - Add a new form field bound to the discount property
  5. Verify - npm run lint, npm run typecheck, visual test

That's it. No hunting for where the API call is made. No wondering if other components need updating. The change is fully contained in the feature module.

When the ticket says "Add a related products endpoint," the workflow is:

  1. types.ts - Add IRelatedProduct interface
  2. api.ts - Add getRelatedProducts() function
  3. queries.ts - Add relatedProducts: (id) => ({ queryKey, queryFn })
  4. hooks.ts - Add useGetRelatedProductsQuery(id)
  5. components/related-products.tsx - Build the UI using the new hook
  6. Wire it in a page - The page imports only the component and places it in the layout

13. Closing Thoughts

The best architecture is the one your team can reason about without a whiteboard. This pattern isn't clever - it's boring. And that's exactly why it worked.

We didn't invent anything new. We took the well-established ideas of separation of concerns, colocation, and unidirectional data flow and applied them with a consistent file naming convention. The result is a codebase where you can open any feature directory and immediately understand how it works.

Three tips if you adopt this:

  1. Enforce the pattern in code review, not by tooling. A linter can't tell if your import is philosophically correct. Teach the team why the layers matter.
  2. Allow exceptions, but document them. We have a few features that deviate - and each one has a comment explaining why.
  3. Keep the standard response type (IResponseType<T>) in shared types. If every API layer unwraps the envelope differently, the pattern breaks.