Frontend
Frontend Engineering
Sub-chapter 1 of Full Stack Engineering · The half of the stack the user can see
The backend can be a masterpiece of clean architecture and the customer will never know. The frontend is the only part they touch. The frontend is where the user decides if your engineering was worth it - a 200ms layout shift, a button that doesn't respond to the keyboard, a form that eats their input, and your beautiful API might as well not exist.
This sub-chapter is the client half of the Welzin stack: how the browser actually paints pixels, how React's mental model works, where the App Router draws the server/client line, and the design-quality bar you're expected to clear. We stay on the client - data fetching here means consuming an API, not building one (that's sub-chapter 2). Opinionated, modern, no class components, no legacy patterns.
Outline
- How the browser renders - DOM, the critical rendering path, what blocks paint
- Semantic HTML & accessibility - don't div-soup; keyboard, ARIA, why it matters
- CSS in practice - utility-first Tailwind, flex, grid, responsive
- Modern TS for UI - the JS/TypeScript you actually use on the client
- The React mental model - components, props, state, the render cycle
- Hooks & their rules - useState, useEffect, useMemo, and the rules of hooks
- Server vs Client Components - the App Router boundary, and where it goes
- Data, loading & error states - fetching and the three states you must handle
- Forms & validation - controlled inputs, schema validation, UX of errors
- Performance & the design bar - bundle size, lazy loading, LCP/INP/CLS, not shipping a template
101 Primer
How the browser actually renders
When HTML arrives, the browser builds the DOM (tree of elements) and the CSSOM (tree of styles), combines them into a render tree, runs layout (where every box goes), then paint and composite. This is the critical rendering path.
What blocks the first paint matters more than anything else you'll tune:
- CSS is render-blocking. The browser won't paint until the CSSOM is ready. Keep critical CSS small (Tailwind's purged build helps).
- Synchronous
<script>in<head>blocks parsing. Usedefer(or modules, which defer by default), or let Next.js handle script ordering. - Layout is expensive. Reading geometry (
offsetHeight) right after a write forces a synchronous reflow ("layout thrashing"). Batch reads and writes.
The single sentence to remember: every byte of blocking CSS and JS is a byte of delay before the user sees anything. Performance is mostly the discipline of shipping less of it.
Semantic HTML & accessibility - stop the div-soup
A page built entirely from <div> and <span> is invisible to a screen reader and unusable by keyboard. Use the element that means what you mean:
// Div-soup - works visually, fails everyone else
<div className="nav">
<div className="link" onClick={go}>Dashboard</div>
</div>
// Semantic - keyboard- and screen-reader-native for free
<nav aria-label="Primary">
<a href="/dashboard">Dashboard</a>
</nav>
Rules we hold:
- A clickable thing is a
<button>or an<a>. Not a<div onClick>. Native elements are focusable, Enter/Space-activatable, and announced correctly - you'd be reimplementing all of that, badly. - Every
<img>hasalt. Decorative images getalt="". - Every form input has a
<label htmlFor>. Placeholder text is not a label. - Reach for ARIA only when HTML can't express it (e.g.
aria-expandedon a custom disclosure). The first rule of ARIA is: don't use ARIA if a native element does the job. - Tab through your feature with the mouse in your lap. If you can't operate it, neither can a chunk of your users - and increasingly, neither can compliance.
CSS in practice - utility-first with Tailwind
Welzin styles with Tailwind. Utilities live in the markup, so you read the component and the design in one place - no jumping to a stylesheet, no naming-things paralysis, no dead CSS.
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<h2 className="text-xl font-semibold tracking-tight">Invoices</h2>
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-sm text-emerald-700">
Paid
</span>
</div>
- Flexbox for one-dimensional layout (a row or a column):
flex,gap-*,items-*,justify-*. - Grid for two-dimensional layout (cards, dashboards):
grid grid-cols-2 lg:grid-cols-4 gap-6. - Responsive is mobile-first: unprefixed utilities are the base;
md:/lg:override at breakpoints up. - Pull repeated patterns into a component, not a custom CSS class. The component is the unit of reuse, not the class name.
Modern TypeScript for the UI
You don't need all of TypeScript - you need the parts that catch UI bugs. Type your props and your data, and let inference do the rest.
type Invoice = { id: string; amount: number; status: 'paid' | 'open' | 'void' }
// Narrow unions beat free-form strings: the compiler enforces the states
const label: Record<Invoice['status'], string> = {
paid: 'Paid', open: 'Open', void: 'Void',
}
// Optional chaining + nullish coalescing for the data you don't control
const display = invoice?.amount ?? 0
any is a hole in the type system - every any is a bug the compiler can no longer catch. Prefer unknown and narrow.
The React mental model
A component is a function that takes props and returns UI. State is data that changes over time and, when it changes, re-renders the component. That's the whole loop: state changes → React re-runs the function → it returns new UI → React diffs and patches the DOM.
function Counter({ start }: { start: number }) {
const [count, setCount] = useState(start)
return (
<button onClick={() => setCount((c) => c + 1)} className="rounded border px-3 py-1">
Clicked {count} times
</button>
)
}
Two mental-model facts that prevent most beginner bugs:
- Render is pure. No fetching, no DOM mutation, no
setStateduring render. Side effects go in event handlers oruseEffect. - State updates are batched and asynchronous.
setCount(count + 1)twice in one handler increments once. Use the updater formsetCount(c => c + 1)when the next value depends on the previous.
Hooks & the rules of hooks
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Item[]>([])
// Derived/expensive values: memoize so they don't recompute every render
const sorted = useMemo(() => [...results].sort(byName), [results])
// Effects sync with the outside world (network, subscriptions, the DOM)
useEffect(() => {
const ctrl = new AbortController()
fetch(`/api/search?q=${query}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setResults)
.catch(() => {})
return () => ctrl.abort() // cleanup cancels the stale request
}, [query]) // re-run only when query changes
return <List items={sorted} />
}
The rules of hooks, non-negotiable:
- Only call hooks at the top level - never inside conditions, loops, or nested functions. React tracks hooks by call order; branching breaks it.
- Only call hooks from React functions - components or custom hooks.
- The dependency array is a contract. List every value the effect reads. Lying to it (e.g. empty
[]while readingquery) is the #1 source of stale-data bugs. The ESLint plugin will catch you; don't silence it without understanding why.
And the most useful negative rule: useEffect is not for transforming data for render. If a value can be computed from props/state during render, compute it there (optionally useMemo). Effects are for external synchronisation only.
Server vs Client Components
In the Next.js App Router, components are Server Components by default. They run only on the server, can be async, can hit a database directly, and ship zero JavaScript to the browser. You opt into a Client Component with 'use client' only when you need interactivity, state, effects, or browser APIs.
// app/invoices/page.tsx - Server Component (no 'use client')
import { getInvoices } from '@/lib/data'
import { Filter } from './filter'
export default async function InvoicesPage() {
const invoices = await getInvoices() // runs on the server, secrets stay server-side
return (
<main className="p-6">
<Filter /> {/* Client Component island for interactivity */}
<ul className="mt-4 space-y-2">
{invoices.map((i) => <li key={i.id}>{i.amount}</li>)}
</ul>
</main>
)
}
// app/invoices/filter.tsx - Client Component
'use client'
import { useState } from 'react'
export function Filter() {
const [q, setQ] = useState('')
return <input value={q} onChange={(e) => setQ(e.target.value)} className="rounded border px-2 py-1" />
}
The rule: keep 'use client' at the leaves. Fetch data and render static markup on the server; push only the interactive bits to the client. This is how you keep bundles small. Never pass functions or class instances as props across the boundary - only serialisable data.
Data, loading & error states
For server-fetched data, prefer fetching in a Server Component. For client-side server state (mutations, polling, caching across components), use TanStack Query - don't reinvent caching with useState + useEffect.
'use client'
import { useQuery } from '@tanstack/react-query'
export function InvoiceCard({ id }: { id: string }) {
const { data, isPending, error } = useQuery({
queryKey: ['invoice', id],
queryFn: () => fetch(`/api/invoices/${id}`).then((r) => r.json()),
})
if (isPending) return <Skeleton /> // loading
if (error) return <ErrorState retry /> // error
return <Amount value={data.amount} /> // success
}
Every fetch has three states - loading, error, success - and you handle all three, every time. A UI that only renders the happy path will show a blank box the moment the network blinks. App Router gives you loading.tsx and error.tsx files for the route level; handle the component level yourself.
Forms & validation
Validate on the client for fast feedback - and again on the server, because the client can be bypassed (that's sub-chapter 2's job). Share one schema with Zod.
'use client'
import { z } from 'zod'
import { useState } from 'react'
const Schema = z.object({ email: z.string().email(), amount: z.number().positive() })
export function InvoiceForm({ onSubmit }: { onSubmit: (v: z.infer<typeof Schema>) => void }) {
const [error, setError] = useState<string | null>(null)
return (
<form
onSubmit={(e) => {
e.preventDefault()
const raw = Object.fromEntries(new FormData(e.currentTarget))
const parsed = Schema.safeParse({ email: raw.email, amount: Number(raw.amount) })
if (!parsed.success) return setError(parsed.error.issues[0].message)
setError(null)
onSubmit(parsed.data)
}}
className="space-y-3"
>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" name="email" className="w-full rounded border px-2 py-1" />
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button className="rounded bg-zinc-900 px-3 py-1 text-white">Send</button>
</form>
)
}
Error UX rules: show errors next to the field, announce them with role="alert", never clear the user's input on a failed submit, and disable the submit button while in flight so they can't double-submit.
Performance & the design bar
The Core Web Vitals are how Google - and your users - grade you:
- LCP (Largest Contentful Paint) - when the biggest thing (hero image, headline) appears. Target < 2.5s. Fix with smaller images (
next/image), server rendering, and fewer blocking resources. - INP (Interaction to Next Paint) - how fast the UI responds to a tap/click. Target < 200ms. Fix by shipping less JS and not blocking the main thread.
- CLS (Cumulative Layout Shift) - how much the page jumps around as it loads. Target < 0.1. Fix by reserving space: set width/height on images, don't inject banners above existing content.
Keep the bundle small: lazy-load heavy or below-the-fold pieces, and keep 'use client' shallow.
import dynamic from 'next/dynamic'
// Heavy chart only loads when actually rendered, not in the initial bundle
const Chart = dynamic(() => import('./chart'), { ssr: false, loading: () => <Skeleton /> })
On composition: build small components and compose them. But don't over-abstract - a prop-drilled <Box> with thirty boolean props is worse than two honest components. Extract a shared component on the second real duplication, not in anticipation of one.
And the bar: ship something that doesn't look like a default template. Deliberate spacing, a real type scale, one considered accent colour, hover/focus states, empty states that aren't a sad grey box. The default create-next-app look says "nobody cared." Care visibly.
Hands-on Checkpoints
- Open any page you've built in Chrome DevTools → Lighthouse. Record LCP, INP, CLS. Fix the worst one.
- Rebuild one screen using only semantic elements (
nav,main,button,a,label). Operate it fully with the keyboard, mouse untouched. - Build a responsive dashboard layout: a card grid that's 1 column on mobile, 2 on
md, 4 onlg, using Tailwind grid utilities. - Write a component with
useState,useEffect(with cleanup + correct deps), anduseMemo. Then delete one effect by computing its value during render instead. - Take a Client Component and split it: move data fetching and static markup into a Server Component, leave only the interactive island as
'use client'. - Build a form with Zod validation, inline field errors (
role="alert"), and a submit button disabled while in flight. Confirm it never loses input on a failed submit. - Lazy-load a heavy component with
next/dynamic. Compare the JS bundle size before and after in the Network tab.
Further reading
- React docs - Thinking in React & Hooks - the official mental model, genuinely well-written
- Next.js App Router docs - Server vs Client Components, fetching, layouts
- web.dev - Learn Core Web Vitals - LCP, INP, CLS explained with fixes
- MDN - HTML elements reference - when you're unsure which element means what
- WAI-ARIA Authoring Practices - accessible patterns for components HTML can't express
- Tailwind CSS docs - read the layout and responsive-design sections cover to cover
Welzin opinion: The frontend is the product to everyone who isn't an engineer. Spend the extra hour on the loading state, the keyboard path, and the layout that doesn't jump - that hour is more visible to the customer than a week of backend refactoring they'll never see.