Function vs class components, JSX to createElement, props, keys, and controlled inputs.
Function vs Class Components
A React component is a function (or class) that takes props and returns a description of UI. Since hooks arrived in React 16.8, function components are the default; class components persist in legacy code and are still the only way to write error boundaries.
Aspect
Function component
Class component
State
useState/useReducer
this.state + setState
Side effects
useEffect
lifecycle methods
this binding
none — no this
must bind handlers
Error boundary
not possible
componentDidCatch
Reuse logic
custom hooks
HOCs / render props
// Function component — props destructured in the signature
function Greeting({ name, count = 0 }) {
return <p>Hello {name}, you clicked {count} times</p>;
}
// Equivalent class component
class Greeting extends React.Component {
render() {
const { name, count = 0 } = this.props;
return <p>Hello {name}, you clicked {count} times</p>;
}
}
JSX Compiles to React.createElement
JSX is not HTML and not a runtime feature — it is syntax sugar that Babel/SWC transpiles into function calls. Understanding the output demystifies keys, children, and why className exists.
// You write:
const el = <button className="btn" onClick={fn}>Save</button>;
// Classic transform emits:
const el = React.createElement(
"button",
{ className: "btn", onClick: fn },
"Save"
);
// createElement returns a plain object (a React element), NOT a DOM node:
// { type: "button", props: { className, onClick, children: "Save" }, ... }
Attributes become the props object; children become props.children.
class → className and for → htmlFor because class/for are reserved JS words.
A capitalized tag compiles to the variable, so the component must be imported/in scope — the new JSX transform (React 17+) auto-imports the runtime, so you no longer need import React just for JSX.
Elements are immutable, cheap plain objects — React builds a tree of them each render and diffs it.
Props vs Children
Props flow one way: parent to child. They are read-only — a child must never mutate its props. children is just a special prop holding whatever you nest between the tags, which is what makes composition work.
Because JSX is expressions, you branch with ternaries and &&, and build lists with .map(). Each list item needs a stable key so React can match elements across renders.
function TodoList({ todos, loading }) {
if (loading) return <Spinner />;
return (
<ul>
{todos.length === 0
? <li>No items</li>
: todos.map((t) => (
<li key={t.id}>{t.text}</li> // stable id, NOT the array index
))}
</ul>
);
}
Controlled vs Uncontrolled Inputs
A controlled input's value is driven by React state — the DOM is a mirror of state. An uncontrolled input keeps its own value in the DOM and you read it via a ref. Controlled is the default recommendation because state is the single source of truth (enabling validation, formatting, conditional disabling).
// Controlled: value + onChange keep state authoritative
function NameField() {
const [name, setName] = React.useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
// Uncontrolled: DOM owns the value, ref reads it on submit
function NameFieldU() {
const ref = React.useRef(null);
const submit = () => console.log(ref.current.value);
return <input ref={ref} defaultValue="" />;
}
Composition over Inheritance
React has no component inheritance story — you build complex UI by composing simple components and passing elements as props. Two idioms dominate: children for generic containers, and named "slot" props for multiple insertion points.
// Slots via props — a layout with named holes
function SplitPane({ left, right }) {
return (
<div className="split">
<div className="left">{left}</div>
<div className="right">{right}</div>
</div>
);
}
<SplitPane left={<Nav />} right={<Content />} />
MODULE 2
React: Hooks
useState, useEffect, useMemo/useCallback, refs, context, reducers, and the rules of hooks.
useState & State Updates
useState returns a [value, setter] pair preserved across renders by call order. State updates are asynchronous and batched; the setter schedules a re-render rather than mutating immediately. When the next value depends on the previous, use the functional updater.
function Counter() {
const [count, setCount] = React.useState(0);
const addThree = () => {
// WRONG: all three read the same stale `count`, net +1
// setCount(count + 1); setCount(count + 1); setCount(count + 1);
// RIGHT: functional updater queues off the latest value, net +3
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
};
return <button onClick={addThree}>{count}</button>;
}
useEffect: Deps, Cleanup, Timing
useEffect runs a side effect after the render is painted. The dependency array controls when it re-runs; the returned function cleans up before the next run and on unmount. This is where subscriptions, timers, and data fetching live.
React.useEffect(() => {
const id = setInterval(tick, 1000);
const sub = socket.subscribe(onMsg);
return () => { // cleanup: runs before re-run & on unmount
clearInterval(id);
sub.unsubscribe();
};
}, [onMsg]); // re-subscribes only when onMsg changes
Deps array
Effect runs
omitted
after every render
[]
once after mount (cleanup on unmount)
[a, b]
on mount + whenever a or b changes (Object.is)
useMemo & useCallback: Referential Stability
Both exist to preserve reference identity across renders. useMemo caches a computed value; useCallback caches a function (it is useMemo(() => fn, deps)). They matter only when a stable reference is a dependency of something — a memoized child, an effect dep, or an expensive recompute.
const sorted = React.useMemo(
() => bigList.slice().sort(cmp), // recompute only when inputs change
[bigList, cmp]
);
const handleSelect = React.useCallback(
(id) => dispatch({ type: "select", id }),
[dispatch] // stable identity for a memo'd child
);
useRef: Mutable Boxes & DOM Access
useRef returns a mutable { current } object that persists across renders and — critically — mutating it does not trigger a re-render. Two uses: grabbing a DOM node, and holding an instance value (timer id, previous value, latest callback) that shouldn't drive rendering.
useContext reads the nearest provider's value, avoiding prop drilling. useReducer centralizes complex state transitions into a pure reducer — preferable to multiple useState calls when the next state depends on the current state and an action, or when transitions are interrelated.
A custom hook is any function named useX that calls other hooks — the mechanism for extracting and sharing stateful logic. It shares logic, not state: each call site gets its own independent state.
Only call hooks at the top level — never inside loops, conditions, or nested functions.
Only call hooks from React function components or other custom hooks.
MODULE 3
Rendering & Reconciliation
The virtual DOM, keys, re-render triggers, React.memo, and concurrent rendering.
The Virtual DOM & Diffing
The virtual DOM is React's in-memory tree of element objects. On each render React builds a new tree, diffs it against the previous one ("reconciliation"), and applies the minimal set of real DOM mutations. Real DOM writes are expensive; comparing plain JS objects is cheap.
A full tree diff is O(n³) in general; React uses heuristics to make it O(n).
Different type at a position → tear down the old subtree and build fresh (state is lost).
Same type → keep the DOM node, update changed attributes, recurse into children.
Lists are matched by key, not by position.
Keys & List Reconciliation
Keys tell React which element is which across renders. Without them, React diffs list children positionally, so inserting at the front makes every row look "changed". Stable keys let React move DOM nodes and preserve their state instead of rebuilding them.
// Prepending "New" with index keys re-labels every row's state.
// With key={item.id}, React moves nodes and keeps each row's input intact.
{items.map((item) => (
<Row key={item.id} item={item} />
))}
What Triggers a Re-render
A component re-renders when its own state changes, when its parent re-renders, or when a context it consumes changes. Notably, a parent re-render re-renders all children by default — even children whose props did not change.
Trigger
Scope
setState in a component
that component + its subtree
Parent re-renders
all children (unless memoized)
Context value changes
every consumer of that context
Props change
the receiving component
React.memo & Referential Equality
React.memo wraps a component so it skips re-rendering when its props are shallow-equal to the previous render. It only helps if the props actually stay referentially stable — which is why it pairs with useMemo/useCallback.
const Row = React.memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.text}</li>;
});
// Parent must keep onSelect stable, or memo is defeated every render:
const onSelect = React.useCallback((id) => select(id), []);
Pitfall: Inline Objects & Functions
Inline object literals, array literals, and arrow functions in JSX create a new reference on every render. That silently breaks React.memo, adds unstable useEffect deps, and forces context consumers to update.
React batches multiple state updates in the same tick into one render. React 18's automatic batching extends this to promises, timeouts, and native event handlers — not just React events. Concurrent rendering lets React interrupt, pause, and resume rendering to keep the UI responsive.
// startTransition marks an update as low-priority/interruptible
const [isPending, startTransition] = React.useTransition();
function onType(e) {
setQuery(e.target.value); // urgent: keeps input snappy
startTransition(() => setResults(filter(e.target.value))); // deferrable
}
// Suspense shows a fallback while a child is waiting (lazy code / data)
<React.Suspense fallback={<Spinner />}>
<LazyDashboard />
</React.Suspense>
MODULE 4
State Management
Local vs global state, Context limits, Redux/Zustand, and server-state with React Query.
Local vs Lifted vs Global State
State placement is the first decision. Keep state as local as possible; lift it to the nearest common ancestor when siblings must share it; reach for global state only when many unrelated parts of the tree need it. Over-globalizing state is a top source of accidental complexity.
Scope
Where it lives
Use when
Local
useState in the component
only that component cares (form field, toggle)
Lifted
nearest common parent, passed as props
a few siblings share it
Global
Context / store
cross-cutting: auth, theme, cart
Server
query cache (React Query)
data owned by the backend
Context and Its Limits
Context solves distribution (avoiding prop drilling), not performance. It is not a state manager — you still need useState/useReducer to hold the value. Its key limitation: any change to the context value re-renders every consumer, with no built-in selector to subscribe to a slice.
Redux is a single immutable store updated only by dispatching actions through pure reducers — a predictable, time-travel-debuggable data flow. Redux Toolkit (RTK) is the modern standard: it removes the boilerplate and uses Immer so you can "mutate" draft state safely.
Store: one object tree, the single source of truth.
Action: a plain object { type, payload } describing what happened.
Reducer: (state, action) => newState, pure, no side effects.
Selector: useSelector(s => s.cart.items) subscribes to a slice.
Zustand: Minimal Global State
Zustand is a small store built on hooks with no provider and no boilerplate. You define a store as a hook and subscribe to precise slices with a selector, so only components reading a changed slice re-render.
Server-state libraries treat remote data as an asynchronous cache keyed by a query key. They give you loading/error states, deduping, background refetching, cache invalidation, and optimistic updates — features you'd otherwise reinvent inside Redux.
Caching: results stored per query key; staleTime controls freshness.
Invalidation: invalidateQueries marks data stale to trigger a refetch.
Optimistic update: write the expected result to cache immediately, roll back in onError.
MODULE 5
Node.js & Express
The event loop, non-blocking I/O, streams and backpressure, and the middleware chain.
The Event Loop & Its Phases
Node runs JavaScript on a single thread and offloads I/O to libuv, which uses a thread pool and the OS. The event loop is the scheduler that decides what callback runs next, cycling through ordered phases on each tick.
timers — setTimeout/setInterval callbacks.
pending callbacks — deferred I/O callbacks.
poll — retrieve new I/O events; execute I/O callbacks.
check — setImmediate callbacks.
close — close event callbacks.
Between every phase transition (and after each macrotask callback), Node drains the microtask queues: process.nextTick first, then Promise callbacks.
Microtasks vs Macrotasks
Microtasks (promises, queueMicrotask, process.nextTick) run to exhaustion before the loop moves to the next macrotask phase. This ordering explains many "why did this log first?" puzzles.
Node's throughput depends on never blocking the single JS thread. Any synchronous CPU-heavy work or *Sync call freezes the loop, and all pending requests stall until it returns.
// BLOCKING: freezes every other request for the duration
app.get("/hash", (req, res) => {
const h = crypto.pbkdf2Sync(req.query.pw, "salt", 1_000_000, 64, "sha512");
res.send(h.toString("hex"));
});
// NON-BLOCKING: async variant runs on the libuv pool, loop stays free
app.get("/hash", (req, res) => {
crypto.pbkdf2(req.query.pw, "salt", 1_000_000, 64, "sha512", (err, h) =>
res.send(h.toString("hex"))
);
});
Streams & Backpressure
Streams process data in chunks instead of buffering everything in memory. Backpressure is the flow-control signal: when a writable's internal buffer fills, write() returns false and you should pause until drain. pipe()/pipeline() handle this automatically.
Express is a chain of middleware functions (req, res, next) executed in registration order. Each either responds or calls next() to pass control along. Routing is just middleware scoped to a method + path.
Express recognizes error middleware by its four-argument signature (err, req, res, next). In Express 4, errors thrown in async handlers aren't caught automatically — you must forward them to next(err) (Express 5 fixes this). To use multiple CPU cores, run a cluster or offload CPU work to worker threads.
IoC/DI, beans and scopes, stereotype annotations, and auto-configuration.
IoC Container & Dependency Injection
Inversion of Control means the framework, not your code, creates and wires objects. Spring's IoC container reads your configuration, instantiates beans, resolves their dependencies, and manages their lifecycle. Dependency Injection is how those dependencies are supplied — you declare what you need and Spring hands it over.
@Service
public class OrderService {
private final PaymentGateway gateway;
// Constructor injection: Spring passes the PaymentGateway bean in
public OrderService(PaymentGateway gateway) {
this.gateway = gateway;
}
}
Injection type
How
Notes
Constructor
params on the constructor
preferred: enables final, fails fast, testable
Setter
@Autowired on a setter
for optional dependencies
Field
@Autowired on a field
concise but hides deps, hard to unit test
Beans & Scopes
A bean is any object the Spring container manages. By default every bean is a singleton — one shared instance per container. Scope controls how many instances exist and how long they live.
Scope
Instances
Use for
singleton (default)
one per container
stateless services
prototype
new one per injection/lookup
stateful, short-lived objects
request
one per HTTP request
web request-scoped data
session
one per HTTP session
per-user session data
Stereotype Annotations
Stereotype annotations mark a class as a Spring-managed component and convey its role. They are all specializations of @Component — functionally similar for scanning, but they document layering and enable role-specific behavior.
@Repository — data-access layer; also translates persistence exceptions into Spring's DataAccessException hierarchy.
@Controller / @RestController — web layer.
@Repository
public class JdbcUserRepo {
// Persistence exceptions thrown here get translated to
// Spring's unchecked DataAccessException automatically.
}
@Autowired & Resolving Ambiguity
Spring resolves dependencies by type. When two beans satisfy the same type, injection is ambiguous and startup fails unless you disambiguate with @Primary or @Qualifier.
public interface Notifier { void send(String msg); }
@Component @Primary
class EmailNotifier implements Notifier { public void send(String m) {} }
@Component("sms")
class SmsNotifier implements Notifier { public void send(String m) {} }
@Service
class AlertService {
// @Qualifier picks a specific bean; without it @Primary (Email) wins
AlertService(@Qualifier("sms") Notifier notifier) { }
}
@RestController & Request Mapping
@RestController = @Controller + @ResponseBody: return values are serialized (usually to JSON via Jackson) straight to the response body, rather than resolved as view names. Mapping annotations bind HTTP methods and paths to handler methods.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public UserDto get(@PathVariable Long id) {
return service.find(id); // serialized to JSON
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDto create(@RequestBody @Valid CreateUserReq req) {
return service.create(req);
}
@GetMapping
public List<UserDto> search(@RequestParam(defaultValue = "0") int page) {
return service.page(page);
}
}
Starters, Auto-configuration & Profiles
Spring Boot starters are curated dependency bundles (e.g. spring-boot-starter-web pulls Spring MVC, Jackson, and embedded Tomcat). Auto-configuration inspects the classpath and your properties, then conditionally creates beans so you get sensible defaults with zero XML. Profiles let one build behave differently per environment.
// Conditional bean creation is the heart of auto-config
@Configuration
@ConditionalOnClass(DataSource.class)
@ConditionalOnMissingBean(DataSource.class)
class DataSourceAutoConfig {
@Bean DataSource dataSource() { /* HikariCP default */ }
}
# application.properties — profile-specific overrides in application-prod.properties
spring.profiles.active=prod
server.port=8080
spring.datasource.url=jdbc:postgresql://db:5432/app
MODULE 7
Spring Boot: Data & Web
Spring Data JPA, the N+1 problem, transactions, DTOs, and REST error handling.
Spring Data JPA Repositories
Spring Data JPA generates repository implementations at runtime from an interface. You extend JpaRepository<Entity, Id> and get CRUD plus paging for free; derived query methods are parsed from the method name into JPQL.
public interface UserRepository extends JpaRepository<User, Long> {
// Parsed into: SELECT u FROM User u WHERE u.email = ?1
Optional<User> findByEmail(String email);
List<User> findByActiveTrueOrderByCreatedAtDesc();
@Query("SELECT u FROM User u JOIN FETCH u.roles WHERE u.id = :id")
Optional<User> findWithRoles(@Param("id") Long id);
}
Entities & Relationships
An @Entity maps a class to a table. Relationships model foreign keys; the critical decision is fetch type. Associations default to LAZY for collections (@OneToMany, @ManyToMany) and EAGER for single-valued (@ManyToOne, @OneToOne).
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // override the EAGER default
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
}
The N+1 Problem & Fetch Strategies
The N+1 problem: you fetch N parent rows in one query, then lazy-loading each parent's association fires one more query per parent — 1 + N total. It's the most common JPA performance bug.
// Triggers N+1: 1 query for orders, then 1 per order for its customer
List<Order> orders = orderRepo.findAll();
orders.forEach(o -> o.getCustomer().getName()); // N extra queries
// Fix 1: JOIN FETCH in a single query
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Fix 2: entity graph
@EntityGraph(attributePaths = "customer")
List<Order> findAll();
Strategy
Queries
Caveat
Lazy + loop
1 + N
the bug
JOIN FETCH
1
row explosion with multiple collections
@EntityGraph
1
declarative, per-query
@BatchSize
1 + N/batch
fewer round-trips, still lazy
Transactions with @Transactional
@Transactional wraps a method in a database transaction via a proxy: begin on entry, commit on normal return, roll back on exception. Within the transaction the persistence context tracks entities so dirty changes flush automatically.
@Service
public class TransferService {
@Transactional
public void transfer(Long from, Long to, BigDecimal amt) {
Account a = repo.findById(from).orElseThrow();
Account b = repo.findById(to).orElseThrow();
a.debit(amt); // no explicit save needed — dirty checking flushes
b.credit(amt);
} // commit here; any RuntimeException -> rollback
}
DTOs & Validation
Never expose JPA entities directly at the API boundary — they leak schema, risk lazy-loading serialization errors, and over-post. Map to DTOs and validate incoming DTOs with Bean Validation annotations, triggered by @Valid.
public record CreateUserReq(
@NotBlank String name,
@Email String email,
@Min(18) int age
) {}
@PostMapping
public UserDto create(@RequestBody @Valid CreateUserReq req) { ... }
Serializing a lazy entity outside a transaction throws LazyInitializationException — DTOs sidestep it entirely.
Entities in the API let clients set fields they shouldn't (mass-assignment); DTOs whitelist inputs.
@Valid failures produce a MethodArgumentNotValidException you translate to a clean 400.
REST Design & @ControllerAdvice
Good REST APIs use nouns for resources, HTTP methods for verbs, and status codes to signal outcomes. Centralize error handling in a @RestControllerAdvice so every endpoint returns a consistent error shape instead of leaking stack traces.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError notFound(EntityNotFoundException e) {
return new ApiError("NOT_FOUND", e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError invalid(MethodArgumentNotValidException e) {
var msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
return new ApiError("VALIDATION", msg);
}
}
Status
Meaning
200 / 201 / 204
OK / Created / No Content
400 / 401 / 403
Bad Request / Unauthenticated / Forbidden
404 / 409
Not Found / Conflict
500
Server Error (bug — never for validation)
MODULE 8
Full-Stack Architecture & Auth
Client-server flow, REST vs GraphQL, CORS, session/JWT/OAuth2, and the BFF pattern.
Client-Server Data Flow
A full-stack request is a chain: browser → (optional CDN/gateway) → API server → database, and back. Understanding each hop's job — and where latency, caching, and auth live — is what "full-stack" really tests.
Browser issues an HTTP request (fetch/XHR) with headers, cookies, and body.
CDN serves static assets; dynamic calls pass to an API gateway / load balancer.
The API server authenticates, authorizes, runs business logic, queries the DB.
Response returns JSON; the client updates state and re-renders.
REST vs GraphQL from the Client
REST exposes fixed resource endpoints; the client takes whatever shape each returns. GraphQL exposes one endpoint where the client specifies exactly the fields it needs, solving over- and under-fetching at the cost of added server complexity and harder HTTP caching.
Aspect
REST
GraphQL
Endpoints
many, per resource
one
Over/under-fetch
common
client picks fields
HTTP caching
native (URL + verbs)
needs client cache layer
Multiple resources
N round-trips
one query
Errors
status codes
usually 200 + errors array
{ "query": "{ user(id: 1) { name orders { total } } }" }
CORS
The browser's same-origin policy blocks JS from reading responses of cross-origin requests unless the server opts in with CORS headers. It's enforced by the browser, not the server — a server without CORS headers still processes the request; the browser just hides the response from your script.
// Spring Boot: allow the SPA origin, send credentials
@Configuration
public class CorsConfig implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry r) {
r.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true); // required to send cookies cross-origin
}
}
Session vs JWT vs OAuth2/OIDC
These answer different questions. Sessions and JWTs are ways to maintain authentication state; OAuth2 is a delegated authorization framework and OIDC adds an identity layer on top for authentication.
Approach
State
Revocation
Scale
Server session
stored server-side, ID in cookie
easy (delete session)
needs shared store
JWT
stateless, signed claims
hard until expiry
no server store
OAuth2/OIDC
tokens from an authz server
via short expiry + refresh
federated / SSO
// A JWT is three base64url parts: header.payload.signature
// eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzUwMH0.SIG
// The signature proves integrity; the payload is READABLE, not encrypted.
Cookies: httpOnly, SameSite & CSRF
Where you store the token decides your threat model. An httpOnly cookie can't be read by JS (blocks XSS token theft) but is sent automatically on every request (opens CSRF). localStorage is the reverse — immune to CSRF, exposed to XSS.
SameSite=Lax/Strict — not sent on cross-site requests (primary CSRF defense).
CSRF token — extra defense for state-changing requests when using cookie auth.
API Gateway & the BFF Pattern
An API gateway is a single entry point in front of many services, centralizing auth, rate limiting, TLS termination, and routing. A Backend-for-Frontend (BFF) is a dedicated backend per client type that aggregates and reshapes downstream calls into exactly what that UI needs.
// BFF endpoint: one client call fans out to many services server-side
app.get("/bff/dashboard", async (req, res) => {
const [user, orders, recs] = await Promise.all([
fetch(USER_SVC + "/me", { headers: auth }).then(r => r.json()),
fetch(ORDER_SVC + "/recent").then(r => r.json()),
fetch(REC_SVC + "/for-user").then(r => r.json()),
]);
res.json({ user, orders, recs }); // one round-trip for the client
});
MODULE 9
Build, Bundling & Delivery
Vite vs webpack, tree-shaking, code-splitting, SSR/SSG/hydration, and Core Web Vitals.
Vite vs webpack
Bundlers turn many source modules into optimized assets the browser can load. webpack bundles everything (via a dependency graph) even in dev. Vite serves source over native ES modules in dev — no bundling — and only bundles for production (using Rollup), which makes dev startup near-instant.
Aspect
webpack
Vite
Dev server
bundles then serves
native ESM, no bundle
Cold start
slower (grows with app)
near-instant
HMR
can slow on large apps
fast, module-precise
Prod build
webpack
Rollup
Config
verbose, very flexible
minimal, opinionated
Tree-shaking & Code-splitting
Tree-shaking removes unused exports from the final bundle; it relies on static import/export (ES modules) so the bundler can prove code is dead. Code-splitting breaks the bundle into chunks loaded on demand, so users download only what a route needs.
// Tree-shakeable: named import, bundler drops the rest of the lib
import { debounce } from "lodash-es"; // NOT: import _ from "lodash"
// Route-level code-splitting via dynamic import + React.lazy
const Settings = React.lazy(() => import("./Settings"));
<React.Suspense fallback={<Spinner />}>
<Settings />
</React.Suspense>
CSR vs SSR vs SSG vs Hydration
The rendering strategy decides where and when HTML is produced, trading off time-to-first-byte, interactivity delay, and server cost. Hydration is the step where client JS attaches event listeners to server-rendered HTML to make it interactive.
Strategy
HTML built
Best for
CSR
in the browser, after JS loads
app-like dashboards behind auth
SSR
per request on the server
dynamic, SEO-sensitive, personalized
SSG
at build time
mostly-static content (blogs, docs)
ISR
build + periodic regeneration
large catalogs that change slowly
Next.js Rendering & Env Config
Next.js lets you pick a rendering mode per route and colocate server and client code. Environment config separates build-time constants from runtime secrets — a frequent source of leaked keys.
// Next.js App Router: server component fetches at request time (SSR-like)
export default async function Page() {
const data = await fetch("https://api/x", { cache: "no-store" }).then(r => r.json());
return <Dashboard data={data} />; // rendered on the server
}
# Only NEXT_PUBLIC_* is inlined into client JS (visible to users)
NEXT_PUBLIC_API_URL=https://api.example.com # safe: public
DATABASE_URL=postgres://... # server-only: never exposed
Caching, CDN & Content Hashing
A CDN caches static assets at edge locations near users. The trick to "cache forever but still deploy updates" is content hashing: the build embeds a hash of each file's contents in its name, so a change produces a new filename and the old cache entry is never served stale.
# Build output: hash changes only when file content changes
dist/assets/index-a1b2c3.js # immutable, cache 1 year
dist/assets/vendor-9f8e7d.css
# Hashed assets: long-lived immutable cache
Cache-Control: public, max-age=31536000, immutable
# index.html: must revalidate so new asset names are picked up
Cache-Control: no-cache
Source Maps & Perf Budgets
Source maps map minified production code back to original source so stack traces and debugging are readable. Lighthouse audits performance against Core Web Vitals; a performance budget is a CI-enforced ceiling on bundle size and metrics to prevent regressions.
LCP (Largest Contentful Paint) — loading; target < 2.5 s.
INP (Interaction to Next Paint) — responsiveness; target < 200 ms.