Beyond the Folder Structure: Building Maintainable React Applications with Feature-Based Architecture
Beyond the Folder Structure: Building Maintainable React Applications with Feature-Based Architecture Introduction One thing I've come to understand from working on frontend applications is that writing code is only one
Beyond the Folder Structure: Building Maintainable React Applications with Feature-Based Architecture
Introduction
One thing I've come to understand from working on frontend applications is that writing code is only one part of building software. Another important part is how we organize that code, especially when we're building an application that will continue to grow.
When a React project is new, it is easy to create folders for components, pages, hooks, services and utilities, and start building. You know where everything is because you've probably written most of it yourself.
But what happens when the application grows to 10 or 20 features? What happens when another developer joins the team, or when you need to change a feature that was built a year ago?
For me, a good architecture should do more than make the folder structure look organized. A developer should be able to find a feature, understand how it works and make changes to it without having to understand the entire application first.
This is why I find feature-based architecture useful. It gives us a way to organize code around the actual work our application does, rather than only around the type of files we're writing.
To explain the approach, let's imagine we're building a healthcare management application. It allows staff to register patients, manage appointments, view medical histories and maintain medication records. We'll use this same application throughout the article so the decisions are easier to follow.
1. The problem with organizing everything by file type
A common React project starts like this:
src/
βββ components/
β βββ PatientCard.tsx
β βββ AppointmentCard.tsx
β βββ MedicationTable.tsx
βββ hooks/
β βββ usePatients.ts
β βββ useAppointments.ts
βββ services/
β βββ patientService.ts
β βββ appointmentService.ts
βββ pages/
β βββ Patients.tsx
β βββ Appointments.tsx
βββ types/
There is nothing fundamentally wrong with this structure. For a small application, it can be simple and effective.
Now imagine I join the team and I'm asked to change the patient registration flow. I open the patients page, then trace its form components, validation, API calls and types across different folders. The files I need are all there, but the code for one feature is spread across the application.
As more features are added, those folders become larger. It can become harder to answer a basic question: Which files belong to patient registration?
This is not necessarily a code-quality problem. The individual files might be well written. The challenge is that the structure tells us what kind of file something is, but not always which business feature it belongs to.
2. Organizing the application around features
Instead of grouping files only by what they are, we can group them by what they do.
src/
βββ app/
βββ features/
β βββ patients/
β βββ appointments/
β βββ medical-history/
β βββ medications/
βββ shared/
βββ main.tsx
If I want to work on appointment scheduling, I have a clear starting point. If I want to change patient registration, I go to the patients feature.
Each feature can keep the files it needs close together:
features/
βββ patients/
βββ api/
β βββ getPatients.ts
β βββ createPatient.ts
β βββ patientKeys.ts
βββ components/
β βββ PatientList.tsx
β βββ PatientForm.tsx
β βββ PatientProfile.tsx
βββ hooks/
β βββ usePatients.ts
β βββ useCreatePatient.ts
βββ types/
β βββ patient.types.ts
βββ index.ts
We're still using familiar React concepts: components, hooks, types and API functions. We're simply keeping the code for one business feature together.
However, feature-based architecture is not just about creating a features folder. We can move every file into that folder and still end up with a difficult application. The real value comes from deciding what each feature owns, what it shares and how it works with the rest of the system.
3. Each feature needs a clear responsibility
Imagine our healthcare application has four features: patients, appointments, medical history and medications.
Let's say we decide to remove appointment scheduling. We would expect to remove the appointment screens and update pages that show appointment information. We would not expect patient registration to stop working because an important patient utility was hidden inside the appointments feature.
That is the kind of problem clear feature ownership helps us avoid.
A feature should own the code needed for its responsibility and depend as little as possible on the internal files of other features. This doesn't mean features can never work together. A patient overview page will naturally need information from several features. The point is to make those relationships deliberate and easy to identify.
A useful question when adding code is: If this feature were removed tomorrow, what else would have to change? If the answer includes several unrelated features, we should look again at where that code belongs.
There is also a practical limit here. A folder structure cannot make every feature independent. Features may share backend data, user permissions and workflows. We should aim for clear responsibilities, not pretend those relationships do not exist.
I find it helpful to think of the main dependencies this way: the application brings features together, features own their business logic, and features can use general tools from shared. When one feature genuinely needs another, it should use a small public interface rather than reach into internal folders. This is a guideline for keeping changes predictable, not a rule that every feature must live in isolation. One boundary I would keep firm is that general code in shared should not import business features. Otherwise, the lowest layer starts depending on the very features it is supposed to support.
4. When should something become shared?
Let's say we have a PatientCard inside the patients feature. Later, the medical history feature needs a card with a similar layout.
One option is to import the whole PatientCard into medical history. That might work, but it also means medical history now relies on a component designed around the patients feature.
I would first ask what we actually need to reuse. If it is just the layout, we can extract a general InfoCard:
src/
βββ features/
β βββ patients/components/PatientCard.tsx
β βββ medical-history/components/HistoryCard.tsx
βββ shared/ui/InfoCard.tsx
// shared/ui/InfoCard.tsx
import type { ReactNode } from "react";
type InfoCardProps = {
heading: ReactNode;
children: ReactNode;
};
export function InfoCard({ heading, children }: InfoCardProps) {
return (
<section className="info-card">
{heading}
{children}
</section>
);
}
For example, the patients feature can use the shared layout while keeping its own content:
// features/patients/components/PatientCard.tsx
import { InfoCard } from "@/shared/ui/InfoCard";
import type { Patient } from "../types/patient.types";
export function PatientCard({ patient }: { patient: Patient }) {
return (
<InfoCard heading={<h2>Patient details</h2>}>
<p>{patient.name}</p>
</InfoCard>
);
}
Now both features can reuse the layout without importing each other's internal components. Each caller supplies a heading such as <h2>Patient details</h2> or <h3>Medical history</h3>, choosing the right level for its page. The shared component provides the wrapper and a CSS styling hook; the feature still decides what information and actions belong inside it. Its actual spacing and appearance would be defined in the shared styles.
But I wouldn't move an entire component into shared just because two screens look similar. A patient card may include actions, validation rules or permissions that only make sense within the patients feature.
Share the general layout, not the patient-specific behavior. The same distinction helps keep shared from becoming a dumping ground. It is a good home for general UI controls, date formatting and HTTP configuration, but not patient forms, appointment workflows or medication rules.
5. Bringing features together on one screen
A patient overview page might show a profile, medical history, medications and upcoming appointments. We do not need to move all that functionality into the patients feature simply because it appears on one screen.
The application can bring the features together while each feature remains responsible for its own part.
// app/routes/PatientOverview.tsx
import { PatientProfile } from "@/features/patients";
import { MedicalHistoryList } from "@/features/medical-history";
import { MedicationList } from "@/features/medications";
import { UpcomingAppointments } from "@/features/appointments";
type Props = { patientId: string };
export function PatientOverview({ patientId }: Props) {
return (
<main>
<PatientProfile patientId={patientId} />
<MedicalHistoryList patientId={patientId} />
<MedicationList patientId={patientId} />
<UpcomingAppointments patientId={patientId} />
</main>
);
}
This example assumes each feature exports the component shown and that those components accept a patientId prop. In a real application, each one would also need appropriate loading, empty and error states.
I like this approach because the route decides what appears on the page, while the features decide how their own functionality works. Passing a patientId is a small, explicit contract; the route does not need to know the internal state or data-fetching logic of each feature. If appointment scheduling is removed, we can update this page without rewriting the medical history or medication features.
Not every feature interaction needs to happen in a route. Sometimes one feature genuinely needs an operation provided by another. When that happens, I would rather expose a small, clearly defined interface than import an arbitrary internal file. I would also watch for chains of features depending on one another, because those can make future changes harder.
6. Give each feature a clear entry point
Consider these two imports:
// Depends on the internal folder structure
import { PatientProfile } from "@/features/patients/components/PatientProfile";
// Depends on a public entry point
import { PatientProfile } from "@/features/patients";
The second approach allows the patients feature to decide what it makes available to the rest of the application.
Inside features/patients/index.ts, we can write:
export { PatientProfile } from "./components/PatientProfile";
If we later reorganize the patients feature, we can update this export without changing every consumer. The import path becomes a small agreement between the feature and the rest of the application. It is not a security boundary, and it does not prevent the features from sharing the same backend data. This is a lightweight convention rather than a full implementation of Feature-Sliced Design, which defines additional layers and import rules.
There is one important detail: an index.ts file does not automatically make the other files private. TypeScript will still allow a direct import if the path is accessible. If the team wants to enforce this convention, we need import rules and code reviews to support it.
I also wouldn't export every hook, type and component simply because we can. The entry point should expose what other parts of the application actually need. Keeping it small gives us more freedom to change the internal implementation later.
7. Organizing API calls and managing data
Instead of putting patient, appointment and medication requests into one large services folder, I prefer keeping each feature's API operations with the code that uses them. The features can still use a shared HTTP client for common configuration:
// shared/api/client.ts
import axios from "axios";
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
This example assumes a Vite application, where import.meta.env.VITE_API_URL is available. Other build setups may read environment configuration differently. Variables exposed to the browser are public configuration, not a safe place for API secrets.
The patient type can stay with the patients feature. For this small example, we'll use only an ID and a display name:
// features/patients/types/patient.types.ts
export type Patient = {
id: string;
name: string;
};
export type NewPatient = { name: string };
Then the patients feature can define its own API operation:
// features/patients/api/getPatients.ts
import { apiClient } from "@/shared/api/client";
import type { Patient } from "../types/patient.types";
export async function getPatients(): Promise<Patient[]> {
const { data } = await apiClient.get<Patient[]>("/patients");
return data;
}
The Patient[] type tells TypeScript what response we expect. It does not check that the server actually returned valid patient records. If the API contract is uncertain or the data is sensitive, runtime response validation may be needed as well.
For these hooks to work, the application must provide a QueryClientProvider near its root. The app/providers folder is a natural place for that setup. Create the QueryClient once for the lifetime of the provider, rather than creating a new one on every render.
If we use TanStack Query, we can keep the query hook with the feature as well. I also prefer one place for its query keys, so the list and registration flow cannot accidentally use different spellings:
// features/patients/api/patientKeys.ts
export const patientKeys = {
lists: () => ["patients", "list"] as const,
detail: (id: string) => ["patients", "detail", id] as const,
};
// features/patients/hooks/usePatients.ts
import { useQuery } from "@tanstack/react-query";
import { getPatients } from "../api/getPatients";
import { patientKeys } from "../api/patientKeys";
export function usePatients() {
return useQuery({
queryKey: patientKeys.lists(),
queryFn: getPatients,
});
}
Now a patient list component can focus on displaying the result:
// features/patients/components/PatientList.tsx
import { usePatients } from "../hooks/usePatients";
export function PatientList() {
const { data: patients, isPending, isError } = usePatients();
if (isPending) return <p>Loading patients...</p>;
if (isError) return <p>Unable to load patients.</p>;
if (!patients?.length) return <p>No patients found.</p>;
return (
<ul>
{patients.map((patient) => (
<li key={patient.id}>{patient.name}</li>
))}
</ul>
);
}
The shared client handles general request configuration. The feature API function knows which endpoint to call. The query hook handles fetching and caching. The component displays the result. Each part has a clear job.
Query keys describe which data is cached. If the list later supports page numbers or filters, those values should be included in its key because they change the result. Keeping the shared patientKeys.lists() prefix lets us invalidate all patient-list variants together. I would keep personal details such as names and medical notes out of query keys.
Patient registration shows why keeping related work together matters. When a new patient is created, the patients feature should own both the request and the decision to refresh its list. I would keep the HTTP operation in api/ and the TanStack Query behavior in hooks/, following the same pattern as the patient-list request:
// features/patients/api/createPatient.ts
import { apiClient } from "@/shared/api/client";
import type { NewPatient, Patient } from "../types/patient.types";
export async function createPatient(input: NewPatient): Promise<Patient> {
const { data } = await apiClient.post<Patient>("/patients", input);
return data;
}
// features/patients/hooks/useCreatePatient.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createPatient } from "../api/createPatient";
import { patientKeys } from "../api/patientKeys";
export function useCreatePatient() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPatient,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: patientKeys.lists() }),
});
}
The form stays in the same feature and calls the mutation hook. Here is a deliberately small example:
// features/patients/components/PatientForm.tsx
import { useState, type FormEvent } from "react";
import { useCreatePatient } from "../hooks/useCreatePatient";
export function PatientForm() {
const [name, setName] = useState("");
const registration = useCreatePatient();
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedName = name.trim();
if (!trimmedName || registration.isPending) return;
try {
await registration.mutateAsync({ name: trimmedName });
setName("");
} catch {
// Keep the name in the form so staff can try again.
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="patient-name">Patient name</label>
<input
id="patient-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
<button type="submit" disabled={registration.isPending}>
{registration.isPending ? "Registering..." : "Register"}
</button>
{registration.isError && <p role="alert">Registration failed. Please try again.</p>}
</form>
);
}
This is an architecture example, not a complete healthcare registration form. A real product needs stronger validation, access checks, consent and careful handling of personal data. Notice that the form doesn't need to know how the patient list is cached. The hook owns that decision. The form uses mutateAsync so it clears the input only after a successful registration; if the request fails, the name stays available for another attempt. Returning the invalidation promise from onSuccess also keeps the mutation pending while matching active queries are refetched. Inactive queries are marked stale but are not necessarily fetched immediately. This follows TanStack Query's documented behavior. The pending button helps avoid repeated clicks, but it does not guarantee that a request cannot be duplicated after a network timeout. In a real registration system, the server should handle retries safely, for example with an appropriate idempotency strategy.
One more thing: not every React application needs an additional global state library. TanStack Query already helps with server data, and React's local state is enough for many UI interactions. If we later have client-side state that several unrelated parts of the application need, then a tool like Zustand may be worth considering. I would introduce it because the application needs it, not simply because it is available.
8. How do we keep the architecture from drifting?
Defining feature boundaries is only the beginning. We also need ways to keep those boundaries intact as the team and codebase grow.
The public entry points discussed earlier are one convention worth checking automatically. ESLint import restrictions or a module-boundary plugin can help enforce them. The details depend on the project's aliases and import patterns: a rule written only for @/ imports might miss relative paths, and static import restrictions do not cover every dynamic import. I would test both allowed and forbidden examples and run those checks in CI.
Code reviews and tests are still necessary. A lint rule can catch a forbidden import, but it cannot reliably tell us whether a supposedly shared component contains business logic that should have stayed in one feature. I would keep tests close to the feature they cover and add an integration test for the registration flow: submit a valid name, mock a successful API response, and check that the patient list refreshes. I would also test the failure path: the form should show an error and keep the entered name so staff can retry. These checks test real user actions rather than only checking whether a folder exists. For a reliable test, I would render the form inside a fresh QueryClientProvider, mock the network request, and wait for the user-visible result. A new query client per test prevents cached patient data from one test affecting another.
I would make the team's expectations short and practical:
- Keep feature-specific components, API operations and rules inside their owning feature.
- Use
sharedfor functionality that does not need to know about a particular business feature. - Compose multiple features in a page or other clearly owned part of the application.
- Import through public entry points when working across feature boundaries.
- Add a new abstraction only when it solves a real problem.
- These are rules a developer can understand and apply during everyday work.
9. Not every feature needs the same folders
One mistake I'd avoid is treating feature-based architecture as a fixed template.
A small notifications feature might only need three files:
features/notifications/
βββ NotificationList.tsx
βββ useNotifications.ts
βββ index.ts
A more complex patients feature might need separate directories for forms, validation, API operations and components. Both structures can exist in the same application.
We shouldn't create folders just because a template says they must exist. We should introduce structure when it makes the code easier to understand and work with.
This also means feature boundaries may change. If patient registration grows into several distinct workflows, we might split it into smaller areas. If two tiny features are always changed together, we might discover that they belong to one larger responsibility. Architecture is something we maintain as the product changes, not a decision we make once and never revisit.
10. When does this approach make sense?
I wouldn't recommend feature-based architecture for every React project. A small landing page with a contact form probably doesn't need public entry points, strict module boundaries and several feature folders.
But consider a healthcare application that starts with patients and appointments, then adds medication management, billing, reporting, staff roles and more workflows. As different developers begin working on those areas, clearer ownership becomes more valuable.
The trade-off is that we now need to think about boundaries, shared code and import rules. That takes some discipline. If the application is too small to benefit from those decisions, the extra structure may slow us down.
I would judge the architecture by everyday development work, not just by how clean the folders look. Suppose a new requirement adds an emergency contact to patient registration. We should be able to find the patient form, its validation, the API contract and its tests within the patients feature. We might need a backend change or an update to a genuinely shared contract, but we should not have to edit unrelated appointment or medication components. I would look at which files changed and whether those changes stayed close to the feature's responsibility. In this example, I would expect changes in patients/components/PatientForm.tsx, the patients validation or types, the patient API contract, and their tests. The appointments feature should not need an edit just because patient registration gained one field. That is a more useful test of the architecture than counting folders.
There is also a limit to what this approach solves. Organizing frontend files does not replace backend authorization, automated tests, accessibility or good API design. Patient records still need server-side access checks. A frontend route guard can guide the interface, but it cannot authorize an API request. I would also avoid putting medical details into query keys, logs or persistent browser storage. If the app caches patient records in memory, it needs a plan to clear or isolate user-specific cached data when staff sign out or switch accounts. For example, the sign-out flow can cancel relevant in-flight queries and clear the QueryClient cache, while also unmounting protected screens and ending the server session. Clearing a cache alone is not an authorization mechanism, and request cancellation must be supported by the HTTP layer to stop work in progress. The same browser should not show one staff member's cached records to another. Those are security and privacy decisions, not benefits that a folder structure provides automatically.
For me, the question is not, Is feature-based architecture always better? It is, Has the application become complex enough that organizing code around its business features will make future changes easier?
11. Bringing everything together
Here is how those decisions could fit together in our healthcare application:
src/
βββ app/
β βββ routes/
β βββ layouts/
β βββ providers/
βββ features/
β βββ patients/
β β βββ api/
β β βββ components/
β β βββ hooks/
β β βββ types/
β β βββ index.ts
β βββ appointments/
β βββ medical-history/
β βββ medications/
βββ shared/
β βββ api/
β βββ ui/
β βββ lib/
βββ main.tsx
This is a starting point, not a required structure. The more useful question is how to introduce it without disrupting an application that already works.
If I were introducing this approach into an existing codebase, I wouldn't move every file at once. I would start with one feature, bring its related files together, define its entry point, run its tests, and check that its imports follow the agreed rules. Then I would migrate the next feature. That makes the change easier to review and avoids turning a folder reorganization into a risky rewrite.
Explore the working example
I have also built CarePoint, a small healthcare management application based on the example used throughout this article. It brings the ideas together in a working React project, including feature-owned components and API calls, shared UI components, and an application layer that composes features.
- Try the application:
- Explore the source code: The repository includes setup instructions, demo sign-in details, and a guide showing where the concepts discussed in this article appear in the code. It uses a mock backend, so you can explore the example without setting up a separate API.
Conclusion
I judge frontend architecture by what happens when someone needs to change a feature. Can they find the relevant code, update a workflow without surprising side effects, and reuse general components without spreading business rules across the application?
Feature-based architecture gives us a practical way to approach those questions. It encourages us to organize code around the functionality we're building, define clear responsibilities and make dependencies easier to see.
Those benefits don't come from creating a features folder alone. They come from clear ownership, deliberate dependencies and a team that maintains those decisions as the product changes. The proof is in everyday work: how quickly a developer can find the right code, how safely a feature can change and how easily its behavior can be tested.
The goal isn't to have the most sophisticated folder structure. It's to have a codebase that developers can understand, maintain and confidently extend.
Further reading
These resources support the ideas discussed here. This article is a practical explanation of one approach, not a claim that React requires a particular folder structure.
- React documentation, Thinking in React: https://react.dev/learn/thinking-in-react
- React documentation, Managing State:https://react.dev/learn/managing-state
- TanStack Query documentation, Query Keys: https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
- TanStack Query documentation, Invalidations from Mutations: https://tanstack.com/query/latest/docs/framework/react/guides/invalidations-from-mutations
- Feature-Sliced Design documentation, Layers and public APIs: https://feature-sliced.design/docs/reference and https://feature-sliced.design/docs/concepts/low-coupling
- ESLint documentation, no-restricted-imports: https://eslint.org/docs/latest/rules/no-restricted-imports
- TanStack Query documentation, QueryClient (including cache clearing): https://tanstack.com/query/latest/docs/framework/react/reference/classes/QueryClient
- OWASP Cheat Sheet Series, HTML5 Security: https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.