How I Structure React Features: The View / Logic / Interface Pattern
Most React projects start clean and then quietly become hard to navigate. The first sign is a page.tsx that scrolls for three minutes — useState, useMutation, inline dialog components, column definitions, and the actual JSX all tangled together.
The pattern I've settled on is straightforward: every feature gets a dedicated folder with three files that each do exactly one thing.
The problem with monolithic page files
In a Next.js App Router project, app/dashboard/sources/page.tsx is a natural dumping ground. You add a useQuery, then a useState for a dialog, then the dialog component itself — and before long the file is 250 lines and impossible to scan.
The deeper issue is that concerns at different altitudes live in the same file. Data fetching logic sits next to pixel-level JSX. Types are either inline or missing entirely. Finding "where does the delete mutation live?" means grepping through a wall of JSX.
The pattern
All feature UI moves to a views/ folder that mirrors the app/ route structure. The route file becomes a three-liner:
// app/dashboard/sources/page.tsx import { SourcesView } from "@/views/dashboard/sources/view"; export default function SourcesPage() { return <SourcesView />; }
Inside views/dashboard/sources/ there are three files:
views/dashboard/sources/ constants.ts ← view-level constants interface.ts ← types introduced by this view logic.ts ← the custom hook view.tsx ← JSX only
logic.ts — the custom hook
Everything stateful goes here: useState, useQuery, useMutation, useRef, event handlers. It exports a single hook that returns what the view needs.
// views/dashboard/sources/logic.ts export function useSourcesPage() { const queryClient = useQueryClient(); const { data: sources = [], isLoading } = useQuery({ queryKey: ["sources"], queryFn: getSources, refetchInterval: (query) => { const data = query.state.data; if (!data?.length) return false; return data.some((s) => PENDING.includes(s.status)) ? 3000 : false; }, }); const deleteMutation = useMutation({ mutationFn: deleteSource, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sources"] }), }); return { sources, isLoading, handleDelete: (id: string) => deleteMutation.mutate(id), invalidate: () => queryClient.invalidateQueries({ queryKey: ["sources"] }), }; }
interface.ts — new types only
Only types that this view introduces live here. If a type already comes from @/server/ or a library, it stays there. I don't re-export for the sake of it.
// views/dashboard/sources/interface.ts export interface SourceRowActions { onDelete: (id: string) => void; }
If there are no new types, the file is simply omitted.
view.tsx — JSX that calls the hook
The view component calls the hook, unpacks what it needs, and passes values down. No logic lives here.
// views/dashboard/sources/view.tsx "use client"; export function SourcesView() { const { sources, isLoading, handleDelete, invalidate } = useSourcesPage(); const columns = getColumns(handleDelete); return ( <div className="p-6 space-y-6"> {/* header + actions */} {isLoading ? <Spinner /> : <DataTable columns={columns} data={sources} />} </div> ); }
Note that "use client" belongs here, not on page.tsx. The route file stays a Server Component — a meaningful win in Next.js App Router.
constants.ts — view-level constants
Anything that's shared across the three files above but doesn't belong in a global config goes here.
// views/dashboard/sources/constants.ts export const PENDING = ["queued", "crawling", "processing"] as const;
Sub-components in _components/
When a piece of UI is complex enough to extract — a dialog, a panel, a form — it moves to _components/ inside the same folder. Sub-components follow the same split:
views/dashboard/sources/ _components/ add-url-dialog/ interface.ts ← AddUrlDialogProps logic.ts ← useAddUrlDialog hook view.tsx ← AddUrlDialog component upload-file-dialog/ interface.ts logic.ts view.tsx columns.tsx ← stateless factory, no split needed
When not to split
The split exists to separate concerns that actually exist. A stateless component — a column definition factory, a static layout card — doesn't have logic or new types to extract. Forcing three files where one will do is noise. The rule: split only when there's something to split.
No barrel files
_components/ does not get an index.ts. Import directly from the file path. Barrel files cause bundlers to include entire directories when you only needed one export — a real cost in Next.js where each page's bundle is optimised separately.
Promoting to shared components
The components in _components/ are local by default. If a component gets used in a second place, that's fine — two usages don't justify abstraction. When a third unrelated view needs the same component, move it to src/components/common/ and update the imports. Don't extract preemptively.
Why this works
Scanning is fast. Open logic.ts and every piece of state is visible in one scroll. Open view.tsx and it's almost entirely JSX.
Testing is easy. The hook in logic.ts is a plain function. You can test it with renderHook without mounting any UI. The view is correspondingly simple to test because it has no logic.
The client boundary is explicit. One "use client" on view.tsx and the route file is a server component. That's a concrete architectural choice that the pattern enforces by default.
Reuse happens at the right moment. You don't design for reuse upfront — you wait for the third usage and then extract. By then the interface is battle-tested.
The mental model
Think of it as altitude. page.tsx is at the highest altitude — it knows only that a SourcesView exists. view.tsx knows the structure of the page. logic.ts knows about data and state. _components/ knows about individual UI pieces. Each altitude has one job and talks to the altitude directly below it.
It's not a framework. It's a folder convention — and that's the point.
Bonus: Drop this into your CLAUDE.md
If you're using an AI coding agent — Claude Code, Cursor, or any tool that reads an instructions file — copy the block below into your CLAUDE.md (or AGENTS.md, .cursorrules, etc.). The agent will follow this pattern without you having to re-explain it each session.
# Views Pattern All feature UI lives under `src/views/`, mirroring the `app/` route structure. Route files in `app/` are thin Server Components that only import and render the view. ## Directory structure ``` src/ app/dashboard/sources/ page.tsx ← Server Component, imports SourcesView, nothing else views/dashboard/sources/ constants.ts ← view-level constants (e.g. PENDING statuses) interface.ts ← types introduced by this view (omit if none) logic.ts ← custom hook (useState, useQuery, useMutation, etc.) view.tsx ← "use client", stitches logic + sub-components _components/ columns.tsx ← stateless — no split needed (see rule below) add-url-dialog/ interface.ts logic.ts view.tsx ``` ## Rules ### The three files | File | Contains | Extension | | -------------- | ----------------------------------------------------------------------- | -------------- | | `view.tsx` | JSX only — calls hook from `logic.ts`, renders sub-components | `.tsx` | | `logic.ts` | Custom hook — all `useState`, `useQuery`, `useMutation`, `useRef`, etc. | `.ts` (no JSX) | | `interface.ts` | Types introduced by this view or component | `.ts` (no JSX) | | `constants.ts` | View-level constants shared across the three files above | `.ts` | ### Client boundary `"use client"` goes on `view.tsx` only. `logic.ts` and `interface.ts` never need it — they're pulled into the client boundary by the import chain. The route `page.tsx` stays a Server Component. ### \_components Sub-components live in `views/…/_components/`. They follow the same view/logic/interface split **only when there is actually something to split**: - Has state or data fetching → create all three files - Purely stateless (e.g. a column definition factory, a static card) → single `.tsx` file, no split ### interface.ts Only create `interface.ts` when the view introduces new types. If all types come from `@/server/` or an external library, skip the file. ### Promoting to shared components If a component ends up used in 3+ places, move it to `src/components/common/`. Don't preemptively extract — wait until the third usage. ### No barrel files Do not add `index.ts` inside `_components/`. Import directly from the file path. Barrel files inflate the bundle (Vercel `bundle-barrel-imports` rule). ### Deduplication with React Query Queries with the same `queryKey` are automatically deduplicated by React Query's cache. Keep hooks in their own `logic.ts` per page and trust the cache. Only extract to `src/hooks/` when the same hook is used in 3+ views.