--- name: frontend-architecture description: "Organizes React/frontend codebases: folder structure, feature boundaries, import direction, state management categories, API layer. Use when scaffolding a frontend app, adding a feature folder, untangling imports, choosing a state library, or deciding where state lives." license: MIT metadata: author: lammanhhoang version: "1.0.0" --- # Frontend Architecture Feature-based React architecture per alan2207/bulletproof-react, hardened to one default library per state category: a fixed `src/` taxonomy, a one-way import law enforced by ESLint, and a typed API layer colocated with features. ## When to use - Scaffolding a new React/Next.js/Vite app. - Adding a feature folder to an existing feature-based codebase. - Untangling cross-feature or upward imports. - Choosing a state library, or deciding where a piece of state lives. - Setting up or reviewing the API/data-fetching layer. When NOT to use: backend/service architecture, publishable component-library packaging (different constraints: barrels and exports maps are fine there), CSS/design-system decisions, or picking the framework itself. ## Core model Three layers, one direction: **shared → features → app**. Lower layers never know about higher ones. Features never know about each other. ## Rules Cite by number ("violates rule #4"). ### Structure 1. `src/` MUST use exactly this taxonomy. Do not invent sibling top-level folders; if something has no home here, it belongs inside a feature. | Folder | Contents | |---|---| | `app/` | Application layer: routes/pages, `app.tsx`, `provider.tsx` (global providers), `router.tsx` | | `assets/` | Static files: images, fonts | | `components/` | Shared components used across the entire application (truly generic: `Button`, `Dialog`, `Table`) | | `config/` | Global configuration, exported env variables | | `features/` | Feature-based modules — most code lives here | | `hooks/` | Shared hooks used across the app | | `lib/` | Reusable libraries preconfigured for the app (axios/ky instance, query client, auth client) | | `stores/` | Global state stores | | `testing/` | Test utilities and mocks | | `types/` | Shared types | | `utils/` | Shared utility functions | 2. Each feature folder MAY mirror the shared taxonomy — `api/`, `assets/`, `components/`, `hooks/`, `stores/`, `types/`, `utils/` — and MUST contain only the folders it actually needs. Never scaffold empty placeholder folders. 3. Code MUST start inside its feature. Promote to shared `components/`/`hooks/`/`utils/` only after the third feature needs it (rule of three). Shared folders MUST NOT contain domain logic — if it mentions a domain noun (`OrderRow`, `useDiscussion`), it belongs in a feature. ### Import direction 4. The import law: `shared → features → app`, strictly one-way. - `app/` MAY import from anywhere. - `features/*` MAY import from shared folders and from itself; MUST NOT import from `app/`. - Shared folders (`components/`, `hooks/`, `lib/`, `types/`, `utils/`, `config/`, `stores/`) MUST NOT import from `features/` or `app/`. 5. Cross-feature imports are FORBIDDEN. `features/discussions` MUST NOT import from `features/comments`. When feature A needs feature B: - Compose them at the app level (a route in `app/` renders both and wires them together via props/callbacks), or - Extract the shared piece down into `components/`/`lib/`, or - Admit they are one feature and merge them. 6. Rule #4 and #5 MUST be enforced mechanically with ESLint `import/no-restricted-paths` zones — copy `assets/eslint-boundaries.example.js` into the project's ESLint config and keep the feature list in sync. Boundary discipline by code review alone does not survive contact with deadlines. 7. Barrel files (`index.ts` re-exports) SHOULD be avoided; import files directly. Current bulletproof-react stance: barrels hurt Vite tree-shaking and performance. Additionally, they are a major source of import cycles. (Exception: a published library's public entry point.) ### State — four categories, one default each 8. Classify every piece of state before choosing a tool. Decision rule: **who owns the source of truth?** | Category | Source of truth | Default | Escape hatch | |---|---|---|---| | Server-cache state | The server | **TanStack Query** | swr | | Global app state | The client, app lifetime | **zustand** | React context for rarely-changing values (theme, locale, authed user) | | Form state | One form's lifetime | **react-hook-form** + zod via `@hookform/resolvers/zod` | — | | URL state | The address bar | Router search params (`useSearchParams` / TanStack Router `validateSearch`) | — | 9. Server data MUST NOT be copied into Redux/zustand/context. It is a cache, not state — TanStack Query owns fetching, caching, invalidation, and staleness. Duplicating it into a client store creates two sources of truth that will disagree. 10. State that must survive refresh or be shareable via link (filters, pagination, active tab, selected id) MUST live in the URL, not in a store. 11. Keep state as close to its usage as possible: one component → `useState`; a subtree → lift + props or local context; app-wide → `stores/` (global) or `features//stores/` (feature-scoped). Reach for zustand only after `useState` and lifting demonstrably fail. ### API layer 12. Exactly ONE configured HTTP client instance lives in `lib/api-client.ts` (base URL from `config/env.ts`, auth header injection, error normalization, 401 handling). Nothing else constructs a client. 13. Each endpoint gets a typed request function plus its query/mutation hook, colocated in `features//api/` — one file per endpoint: ```ts // features/discussions/api/get-discussions.ts import { queryOptions, useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; import type { Discussion } from '@/types/api'; export const getDiscussions = (page = 1): Promise<{ data: Discussion[] }> => api.get('/discussions', { params: { page } }); export const getDiscussionsQueryOptions = (page = 1) => queryOptions({ queryKey: ['discussions', { page }], queryFn: () => getDiscussions(page), }); export const useDiscussions = (page = 1) => useQuery(getDiscussionsQueryOptions(page)); ``` 14. Components MUST NOT fetch inline. No `fetch`/`axios` calls in component bodies or `useEffect` — components call the feature's query hooks. Grep check: `fetch(`/`axios.` should only match `lib/` and `features/*/api/`. 15. Query keys MUST be namespaced by feature (`['discussions', ...]`) and defined next to the query, not in a global keys file. ### Components 16. Colocate tests next to the component: `card.tsx` + `card.test.tsx` in the same folder (feature or shared). 17. Container/presentational split only when it pays — when the presentational part is reused with different data sources or is meaningfully testable in isolation. Do not split by default; a component that fetches via a query hook and renders is fine. 18. No premature abstraction: duplicate twice, abstract on the third occurrence (rule of three). A wrong abstraction costs more than duplication. ## Next.js App Router mapping When Next.js's `app/` directory is the router, it REPLACES the taxonomy's `app/` layer: route files (`page.tsx`, `layout.tsx`) stay thin and delegate immediately to `features//components/*`. Everything else — `features/`, `components/`, `lib/`, and the import law — is unchanged. For the exact tree and the `src/app` vs root-`app` choice, read references/STRUCTURE.md (section 6). ## Gotchas - `import/no-restricted-paths` zone semantics trip people up: `target` = the files being restricted, `from` = what they may not import. `{ target: './src/features', from: './src/app' }` means "features cannot import app". - The cross-feature zone needs one entry per feature (with `except: ['./']`) — adding a feature without updating the ESLint feature list silently un-guards it. - Path aliases (`@/*`) don't confuse `no-restricted-paths` (it resolves real paths), but they DO require `eslint-import-resolver-typescript` to resolve at all. - Context for global state re-renders every consumer on any change — that is why zustand (selector-based subscriptions) is the default and context is only for rarely-changing values. - `useEffect` + `useState` fetching is the smell that predicts this whole skill: it means server-cache state is being hand-rolled. Replace with a query hook in `features//api/`. ## What NOT to do - Do not organize `src/` by technical type globally (`/containers`, `/reducers`, `/screens` at top level) — type-folders scatter every feature across the tree. - Do not import between features "just this once". - Do not put server responses in Redux/zustand. - Do not create `index.ts` barrels per folder. - Do not build a `services/` god-layer; API code is per-feature in `features//api/`. - Do not add Redux to a new app; the four defaults in rule #8 cover it. ## Files - references/STRUCTURE.md — full annotated tree, per-folder rationale, feature anatomy, Next.js App Router mapping, migration order. Load when scaffolding a new app or migrating an existing codebase to this layout. - assets/eslint-boundaries.example.js — drop-in `import/no-restricted-paths` zones config (flat + legacy). Install whenever the taxonomy is adopted.