Architecture v3 — React SPA (Feature-First, No Entities)
1. Layers
3 layers. Import only downward.
| Layer | Purpose | Can import |
|---|---|---|
| Pages | Route entry points, layout composition | Features, Shared |
| Features | All business code — data models and user actions | Features (barrel only), Shared |
| Shared | Reusable code with no business affiliation | Shared only |
Pages → Features → Shared
↓
Domain features → Foundation features → Shared
2. Project Structure
src/
├── app/ # shell: routing, layouts, styles, assets
│ ├── routes/
│ ├── layouts/
│ ├── App.tsx
│ └── main.tsx
│
├── shared/ # no business affiliation
│ ├── components/ # one folder per component
│ │ ├── Button/ # code, CSS module, test, barrel
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── Card/
│ ├── api/ # fetch client, telemetry, useApiError
│ ├── lib/ # date, formatters, validators
│ └── config/ # env, constants, feature flags
│
├── pages/ # 1:1 with routes
│ └── <page>/
│ ├── <Page>.tsx # page component
│ ├── <Page>.module.css
│ ├── <Page>.test.tsx
│ └── model.ts # route params, page-level side effects
│
├── features/ # all business code
│ ├── user/ # foundation feature (data model)
│ │ ├── model.ts # types, schemas, objects — no functions
│ │ ├── api.ts # CRUD (internal)
│ │ ├── hooks/ # one hook per file
│ │ │ ├── useUser.ts
│ │ │ └── useUpdateUser.ts
│ │ ├── stores/ # Zustand — only if state escapes the component
│ │ │ └── useDialogStore.ts
│ │ ├── components/ # one folder per component
│ │ │ ├── UserAvatar/
│ │ │ │ ├── UserAvatar.tsx
│ │ │ │ ├── UserAvatar.module.css
│ │ │ │ ├── UserAvatar.test.tsx
│ │ │ │ └── index.ts
│ │ │ └── UserBadge/
│ │ ├── lib/ # pure helpers
│ │ └── index.ts # barrel (public contract)
│ │
│ ├── auth/ # domain feature (user action)
│ │ ├── model.ts
│ │ ├── api.ts
│ │ ├── hooks/
│ │ ├── components/
│ │ └── index.ts
│ │
│ └── checkout/ # domain feature
│ ├── model.ts
│ ├── api.ts
│ ├── hooks/
│ ├── components/
│ └── index.ts
No top-level store/ directory. All Zustand stores live in features — one store per file in stores/.
3. Feature Internal Structure
Every feature has the same shape. No distinction between “entity” and “business” features in folder structure.
| File | Purpose | Public? |
|---|---|---|
model.ts |
Types, schemas, invariants, domain constants and factory objects. No functions — behavior lives in hooks/, stores/, lib/, or api.ts. |
Yes — exported in barrel |
api.ts |
Semantic API calls (business intent, not URLs). No React Query. No side effects. | No — internal only |
hooks/ |
One hook per file (useUsers.ts, useUpdateUser.ts). React Query (useQuery, useMutation), semantic-intent telemetry (useTrace). The only layer allowed to call services. |
Yes — exported in barrel |
stores/ |
Zustand stores, one store per file — only when state outlives the component or is consumed outside it (rare; default to local useState). |
Yes if cross-feature |
components/ |
One folder per UI component: <Component>.tsx, <Component>.module.css, <Component>.test.tsx, index.ts. Presentation-only, props or hook driven. |
Yes — exported in barrel |
lib/ |
Pure helpers (formatters, validators), no side effects. | Yes — exported in barrel |
index.ts |
Barrel export. The feature’s public contract. | — |
Default: component-scoped UI state uses local useState inside the component. A Zustand store in stores/ is only for state that outlives the component or is consumed outside it (e.g., a dialog’s open state read by a sidebar in another feature). Cross-feature UI state lives in the feature whose domain owns it, exported via barrel for other features to consume.
Internal chain:
components/ → hooks/ → api.ts → shared/api/client → HTTP
model.ts is data only — imported, never called. Components and hooks read its types, schemas, and constants.
shared/api/client is transport: base URL, auth, correlation ID, failure normalization. api.ts is intent: the endpoint, the payload, and the mapping into model.ts. Each function carries a stable operation name for useTrace, and it is the only place backend shape changes land. Because the barrel never exports it (section 4), no other feature can bypass caching, invalidation, and tracing to call it, which is what makes rule 6 checkable.
4. Barrel Contract
index.ts is the feature’s public API. Other features import from @/features/<name> — never from internal paths.
Exported: types, hooks, stores (if cross-feature), lib functions, public UI components.
Not exported: api.ts functions, internal sub-components, internal hooks.
5. Feature Types
Two conceptual roles. Same structure. Different dependency rules.
Foundation features — data models (“what things are”). Naming heuristic: nouns (user, order, product). Not a hard rule.
- Depend only on
shared/. Never on other features. - Own the types. Other features consume, never duplicate.
api.tshas CRUD. Exposed only via hooks (hooks/), re-exported by barrel.- Export entity UI components (avatars, badges, cards) in the barrel for cross-feature reuse.
Domain features — user actions (“what users do”). Naming heuristic: domain actions (auth, checkout, dashboard). Not a hard rule.
- Depend on
shared/+ foundation features (via barrel). - Import types, hooks, lib, and UI components from foundation features.
- Never import from other domain features. If two domain features need shared logic, extract it to a foundation feature.
- Never import
api.tsfrom any feature.
Extract to a foundation feature when a type or hook is needed by 2 or more domain features.
6. Cross-Feature Import Rules
| Import | Domain → Foundation | Domain → Domain | Foundation → any feature |
|---|---|---|---|
| Types (from barrel) | ✅ | ❌ | ❌ |
| Hooks (from barrel) | ✅ | ❌ | ❌ |
| Lib functions (from barrel) | ✅ | ❌ | ❌ |
| UI components (from barrel) | ✅ | ❌ | ❌ |
api.ts |
❌ | ❌ | ❌ |
Internal paths (@/features/x/api) |
❌ | ❌ | ❌ |
Domain features import from foundation features only. Domain→domain imports are forbidden — extract shared logic to a foundation feature. Foundation features depend on shared/ only, never on other features.
Lint enforcement: ESLint no-restricted-imports blocking @/features/*/api and cross-domain imports.
7. Pages
Route entry points. 1:1 with URLs.
Contains: page UI component, route param extraction, page-level side effects (scroll-to-top, analytics page-view). Never contains: data fetching, React Query, Zustand, API calls, business logic, reusable UI.
Pages compose features and shared UI. Never import from other pages.
8. API Client (shared/api/client.ts)
Single fetch wrapper. Responsibilities: base URL, auth token injection, correlation ID, request execution, normalize all failures into ApiError, dependency telemetry. Never leaks raw errors.
ApiError: { type: 'network'|'auth'|'validation'|'server', status?, title, detail?, errors? }
9. Telemetry
Azure Application Insights. SDK usage only in shared/api/telemetry.ts.
Must trace: query start/failure, mutation start/success/failure, HTTP dependencies, user-visible notifications, auth/network fatal errors. No silent paths.
Ownership: Feature hooks own semantic-intent tracing (useTrace calls). shared/api/client owns HTTP dependency telemetry. useApiError owns user-visible error tracing.
10. React Query
Queries: read-only. Cached globally. Key format: ['<feature>', '<resource>', ...]. Owning feature manages invalidation.
Mutations: state-changing. User-initiated. Must emit telemetry (start, success, failure), invalidate queries, notify on error via useApiError. Both telemetry and notification fire — neither is optional.
Cross-feature invalidation: When a mutation in one feature affects another feature’s queries, the owning feature exports an invalidation hook in the barrel (e.g., useInvalidateUser). The mutating feature calls that hook. Never reach into another feature’s queryClient calls directly.
11. Error Handling
A component may render a state. It never decides what an error means.
| Stage | Owner |
|---|---|
| Classify | shared/api/client (raw failure becomes a typed ApiError) |
| Decide, notify | useApiError (visibility, severity, ProblemDetails, dedupe, trace) |
| Render | component (branch only, never reads error.type or composes copy) |
| Contain | Error Boundary per feature (render and lifecycle crashes) |
Notification rules:
| Context | Notify? |
|---|---|
| Mutation error | Always |
| Query — initial blocking load | Yes |
| Query — background refetch | No |
| Query — polling | No |
| Network / auth fatal errors | Yes |
Rules:
- Visibility is centralized because rule 15 holds only if one file decides.
useApiErroris called from hooks, never from components.- A query failure renders the branch the hook produces.
- Validation errors map to form fields in the mutation hook, the only layer that sees
ApiError. - Crash containment is a boundary, not a handler: one primitive in
shared/components/, instantiated per feature with a name for telemetry. - Invalidation, navigation, and notification are sequencing, so they live in the hook. A component calls one function.
- Never in a component:
try/catch,error.typeswitches, toast calls, retry counters, multi-stepawaitchains.
12. Local State vs Zustand
Default: local useState. Component-scoped state that lives and dies with the component stays in useState. This covers the majority of UI state — form inputs, toggle flags, selected items in a list, any data that no other component needs.
Zustand is the exception, not the rule. Reach for it only when:
- State must outlive the component (e.g., a multi-step wizard spanning route changes).
- State is consumed outside the component (e.g., a sidebar that reads the current page context).
Zustand scope: Only UI state (dialogs, steps, filters), ephemeral cross-feature state. Never: server entities, lists, cached data, error state, retry counters, telemetry state.
All Zustand stores live in features — one store per file in stores/, exported via barrel if cross-feature. No top-level store/ directory. React Query is the only source of server state.
13. Components
Presentation-only. Receive data via hooks or props. No API calls, no mutations, no orchestration, no error handling, no telemetry. Display loading/empty/success states. Error policy lives in section 11; a component renders the branch that policy produces.
One folder per component: <Component>.tsx, <Component>.module.css, <Component>.test.tsx, index.ts. Styles never live outside the component folder. Global styles (tokens, resets) live in app/.
Per-feature Error Boundaries. One crash must not break the page.
14. Component Design
- Pure helpers outside component body. No closure over state.
- Error Boundaries per feature, not only at root.
- Split when >1 responsibility.
- Destructure props. Related values as single object.
- No nested render functions. Extract to named component.
- Configuration objects for repetitive markup. Loop, don’t hardcode.
- Defaults in destructuring, not
Comp.defaultProps. - Trust React 19 Compiler. Skip
useMemo/useCallback/React.memounless flagged.
15. Accessibility
Semantic HTML (<nav>, <main>, <section>, <article>, <aside>). Icon-only buttons: aria-label. Form inputs: associated <label>. Focus: modals trap, route changes move to heading. Images: alt required. Mantine defaults pass AA; verify overrides.
16. Testing
| Layer | Type | What | Mock |
|---|---|---|---|
| Features | Integration + unit | Hook → api → client chain; pure types/lib; Zustand state transitions | HTTP (MSW); nothing for pure |
| Pages | Component / E2E | Render with mocked hooks; critical flows | Feature hooks, router |
| Shared | Unit | Utilities, formatters, config | Nothing |
Component tests are colocated: <Component>.test.tsx sits next to <Component>.tsx in the component folder.
Keep Vitest tests narrow: one target module, or a feature’s own hook → api → client chain. Mock the boundary, not the layers inside it (HTTP via MSW for feature tests, imports for pure units).
17. Routing
Wouter. One route → one page. Routes in app/routes/ map URLs to pages. Zero business logic in routing. Auth via PrivateRoute wrapper.
18. Performance
Code splitting: route-level React.lazy() + <Suspense>; feature-level lazy load on user intent. Bundle budget: no chunk > 200 KB uncompressed. Prefetch via queryClient.prefetchQuery or link hover. Monitor LCP/INP/CLS. SPA-first; SSR not assumed but achievable.
19. DevEx
Absolute imports only (@/ alias). Import order: React → libs → shared → features → pages → styles. Barrel exports per feature. File naming: component folders and components PascalCase, CSS modules <Component>.module.css, hooks useCamelCase with use prefix, helpers camelCase. Fixed filenames: api.ts, model.ts, index.ts. One hook per file in hooks/, one Zustand store per file in stores/. 2-space tabs. Format on save.
20. Tool Stack
| Concern | Tool |
|---|---|
| Server state | React Query (TanStack) |
| UI state | Zustand |
| Routing | Wouter |
| UI components | Mantine |
| Telemetry | Azure Application Insights |
| HTTP | native fetch via shared/api/client.ts |
| Testing | Vitest + MSW + Playwright |
Replacements only affect shared/. Features and pages unaffected.
21. Hard Rules
- Import only downward: Pages → Features → Shared.
- Pages own layout composition, not data.
- All business code in features. No separate entity concept.
- Foundation features depend only on shared. Domain features depend on shared + foundation. Domain→domain imports forbidden.
api.tsalways internal. Cross-feature consumption via barrel (types, hooks, lib, UI) only.- Hooks are the only layer allowed to call services.
model.tsis data only — types, schemas, objects. No functions.- React Query owns all server state.
- Zustand never mirrors server data. All Zustand stores live in features (
stores/, one store per file), exported via barrel if cross-feature. No top-levelstore/. - Query keys feature-namespaced. Only owning feature invalidates. Cross-feature invalidation via exported invalidation hooks.
- Mutations always notify; queries selectively.
- Components never handle errors or orchestration.
- Error Boundary per feature UI.
- No feature call without telemetry. No user-visible error without trace.
- Application Insights SDK only in
shared/api/telemetry.ts. - Every feature has a barrel (
index.ts). Cross-feature imports go through barrels, never internal paths. - Cross-feature imports: types, hooks, lib, UI — never
api.ts. Domain features import from foundation features only. - Foundation features never depend on other features.
- Semantic HTML and accessibility not optional.
- If it cannot be traced, it must not exist.
- No exceptions.