How to organize frontend code — separation of concerns (UI / logic / data / type), file responsibility, state tiers, API services, schema validation, and framework conventions for React/Next and Vue. Structural rules, not visual design.
70
87%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
How to organize frontend code so it scales. Separation of concerns over file-type folders. Applies to React/Next and Vue. For directory layout, follow app-builder. For visual design, see frontend-design. For React/Next performance, see nextjs-react-expert.
Split code into four layers by responsibility. A unit of code does ONE of these, not several:
| Layer | Holds | Lives in |
|---|---|---|
| UI | Rendering, markup, presentational state | components/ |
| Logic | State, effects, data transforms, reusable UI logic | hooks/ (React) · composables/ (Vue) |
| Data | API calls, fetch/axios, cache keys | lib/ / service files (*.api.ts) |
| Type | TypeScript types, domain models | types.ts / *.types.ts |
| Validation | Form/data schemas | *.schema.ts (zod/yup/valibot) |
Directory layout (top-level folders) follows the project's scaffolding skill — do not invent a competing structure here. This skill is about which layer code belongs to, not where the folders sit.
One clear responsibility per file. Size is a signal, not a hard limit — a clear 230-line file beats a 90-line file that fetches, validates, renders, and juggles modals + toasts.
| File type | Comfortable range |
|---|---|
| UI component | 80–180 lines |
| Page / screen | 100–220 lines |
| Hook / composable | 40–150 lines |
| API service | 50–200 lines |
| Type / schema | flexible |
Split a file when it mixes UI + API + business logic + validation + state. Don't split a coherent file just to hit a number.
Components should primarily render. Push fetching/state/transforms into a hook or composable.
// ❌ Component owns the data layer
function ProductList() {
const [products, setProducts] = useState([])
useEffect(() => { fetch('/api/products').then(r => r.json()).then(setProducts) }, [])
return <div>{/* render */}</div>
}
// ✅ Component renders; logic lives in a hook
function ProductList() {
const { products, isLoading } = useProducts()
if (isLoading) return <Loading />
return <div>{/* render */}</div>
}use.In the App Router, page.tsx and layout.tsx are Server Components. Reach for "use client" only when you actually need the client.
| Server Component | Client Component |
|---|---|
| Fetch data, read DB/API | Form, modal, dropdown |
| Handle secret tokens | Event handlers, animation |
| Render static/semi-static layout | useState/useEffect, browser APIs (window, localStorage) |
Keep client components small. Don't "use client" a whole page for one interactive button — extract the button into its own client component and keep the page a Server Component.
For full production apps, prefer the Composition API with <script setup> Single File Components. (Options API is fine for simple cases / progressive enhancement.)
components/ → UIcomposables/ → reusable pure logic (useX)Use a composable for reusable pure logic; use a component when reusing both logic and layout.
| Need | Use |
|---|---|
| Component-internal state | useState / ref |
| Reusable state/logic in one feature | custom hook / composable |
| Shared UI state in a subtree | Context (React) / provide-inject (Vue) |
| Cross-app, complex, persisted | Zustand / Pinia / Redux |
| Server state / API cache | TanStack Query (react-query) / similar |
Don't reach for global state (or Redux) on day one of a small app. Server state belongs in a query library, not a global store.
Never scatter raw fetch/axios across components.
// user.api.ts
export async function getUsers() {
const res = await http.get('/users')
return res.data
}
// useUsers.ts
export function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: getUsers })
}Always handle loading, error, and empty states explicitly.
Don't inline long validation inside a component.
Keep schemas in *.schema.ts next to the form they validate.
Descriptive, not cryptic. Context from the folder is allowed, but lean explicit.
| Prefer | Avoid |
|---|---|
UserProfileCard.tsx | Card.tsx |
useCreateBooking.ts | handle.ts |
booking.api.ts | api.ts (bare) |
booking.schema.ts | data.ts |
Type props explicitly. When a component takes many related fields, pass the object, not a scatter of primitives.
// ✅
type ProductCardProps = { product: Product; onSelect?: (p: Product) => void }
// ❌ seven loose props
<ProductCard id={id} name={name} price={price} image={image} discount={discount} stock={stock} />Split a component when it shows these tells:
useEffect/watchuseState/refif/else business branchesDecompose along the page seams:
Page
├─ Header
├─ Filter
├─ Table / List
├─ Pagination
└─ Modal / FormclassName runs past ~5–8 logical groups, extract a component.className — use cn().// ✅
const cardClassName = cn(
'rounded-xl border p-4',
isActive && 'bg-blue-500 text-white',
isError && 'border-red-500',
)strict: true.any.Don't test everything up front; do cover:
Colocate (useBooking.test.ts next to useBooking.ts) or keep tests/unit + tests/e2e — pick one and stay consistent.
<button> for actions, not <div onClick>.alt.Remember: one file = one responsibility · UI doesn't own logic · logic → hook/composable · API → service · validation → schema · types separate · Next is server-first · Vue is composable-first. Directory structure comes from the scaffolding skill, not from here.
04514f0
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.