Guide
React fundamentals explained
React is a JavaScript library for building user interfaces by describing what the screen should look like for a given state — not by imperatively mutating the DOM step by step. You compose small components that accept props (inputs from parents) and manage local state (data that changes over time). When state or props change, React reconciles a virtual representation of the tree and updates only the DOM nodes that actually differ. That declarative model scales from a single toggle button to dashboards with thousands of rows, and it pairs naturally with TypeScript, server rendering, and modern bundlers. This guide covers the mental model, the hooks you will use daily, and the pitfalls that make React apps feel slow or buggy.
Declarative UI — describe the result, not the steps
In vanilla JavaScript you might write: find the element, clear its text, append a new node, attach a listener. In React you write a function that returns JSX describing the UI for the current data:
function Greeting({ name }) {
return <h1>Hello, {name}</h1>;
}
When name changes, React re-runs Greeting, compares the new
output to the previous render, and patches the real DOM. You rarely call
document.querySelector yourself. The benefit is locality: the markup,
styles, and behavior for one feature live together instead of scattered across
selectors and global listeners.
React is a view layer. It does not prescribe routing, data fetching, or global state — ecosystems like Next.js, React Router, TanStack Query, and Redux fill those gaps. Keep that boundary in mind: React answers "what should be on screen right now?" not "where is my database?"
Components, JSX, and the component tree
Function components
Modern React uses function components exclusively (class components
are legacy). A component is a function whose name starts with a capital letter and
returns JSX — syntax that looks like HTML but compiles to
React.createElement calls. Attributes use camelCase (className
instead of class, onClick instead of onclick).
Composition over inheritance
Build UIs by nesting components: a Page renders a Header,
Sidebar, and Main. Pass data down via props; lift shared
state up to the nearest common ancestor when siblings need the same value. Deep prop
drilling is a smell — later you may reach for
Context or a dedicated store, but start simple.
Fragments and keys
Wrap multiple siblings in a Fragment (<>...</>)
when you need a group without an extra DOM node. When rendering lists, each item needs
a stable key prop so React can match rows across updates — use database
IDs, not array indices, when items can be reordered or deleted.
Props — read-only inputs from parents
Props flow one way: parent to child. Treat them as immutable inside the child. If a child needs to notify the parent of a change, pass a callback:
function Counter({ count, onIncrement }) {
return (
<button type="button" onClick={onIncrement}>
Count: {count}
</button>
);
}
Destructure props in the parameter list for clarity. Default values work via
JavaScript defaults or defaultProps (less common in modern code).
Document complex prop shapes with
TypeScript interfaces
or PropTypes during migration.
Avoid copying props into state unless you truly need an editable draft — otherwise you create two sources of truth that drift apart.
State with useState
State is data owned by a component that triggers a re-render when
updated. The useState hook returns a value and a setter:
const [count, setCount] = useState(0);
// Functional update when next state depends on previous
setCount(c => c + 1);
State updates are asynchronous and batched. Reading
count immediately after setCount still shows the old value
until the next render. Multiple setters in one event handler batch into a single
re-render in React 18+.
Object and array state
React compares state by reference. When updating objects or arrays, spread into a new reference instead of mutating in place:
setUser(u => ({ ...u, email: newEmail }));
setItems(items => [...items, newItem]);
For complex trees, consider useReducer — same idea as Redux reducers,
colocated in one component — or a library once local state stops scaling.
Side effects with useEffect
Rendering must stay pure: given props and state, return JSX without surprising side
effects. Network calls, subscriptions, timers, and manual DOM measurements belong in
useEffect:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser);
return () => controller.abort(); // cleanup on unmount or dep change
}, [id]);
The dependency array controls when the effect re-runs. Omit it and the effect runs
after every render (rarely what you want). An empty array [] runs once
on mount. Stale closures are a common bug — if you reference count inside
an effect, include count in the dependency array or use a functional
update.
Data fetching increasingly moves to dedicated libraries (TanStack Query, SWR) or
framework loaders in Next.js that handle caching, deduplication, and loading states.
useEffect fetch-on-mount still appears in tutorials and small apps; know
its race-condition and waterfall limitations.
Event handling and controlled inputs
Pass functions to JSX event props. React pools synthetic events in older versions;
in React 17+ events are no longer pooled — still avoid async gaps before reading
event.target if you defer work.
A controlled input ties value to state so React is the
source of truth:
const [email, setEmail] = useState('');
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
Uncontrolled inputs with ref are fine for one-off forms or file inputs.
For validation and instant feedback, controlled components win.
Context — sharing data without prop drilling
createContext plus useContext broadcasts a value to any
descendant without passing props through every intermediate layer. Typical uses: theme
(light/dark), locale, authenticated user snapshot, or feature flags.
Context is not a replacement for a full state manager. Any consumer re-renders when the context value changes — split contexts by concern (theme vs auth) and memoize provider values to avoid needless renders. For high-frequency updates (mouse position, animation frames), context is usually the wrong tool; pass props or use external stores.
Rendering performance — when to optimize
React is fast by default. Optimize after measuring, not preemptively. Useful tools:
- React DevTools Profiler — find components that re-render too often.
React.memo— skip re-render if props are shallow-equal.useMemo/useCallback— cache expensive computations and stable function references when passing callbacks to memoized children.- Virtualization — render only visible rows in long lists (react-window, TanStack Virtual).
Heavy work still blocks the main thread. Offload parsing, cryptography, or physics to
Web Workers or WASM.
Long tasks hurt
Interaction to Next Paint (INP)
— the same responsiveness metric that matters for content sites and dashboards alike.
Understand the
JavaScript event loop
before sprinkling useMemo everywhere.
React in a modern stack
Build tooling
Vite is the default dev server for new projects: fast HMR, native ESM, and a production Rollup build. Create React App is deprecated; Next.js dominates full-stack React with file-based routing, API routes, and SSR/SSG/ISR built in.
Styling
Options coexist: CSS Modules, Tailwind utility classes, Flexbox and Grid in global CSS, CSS-in-JS (styled-components, Emotion), or component libraries (Radix, shadcn/ui). Pick one primary approach per app for consistency.
Testing
React Testing Library encourages tests that resemble user behavior — click, type, assert visible text — rather than testing implementation details. Pair with Vitest or Jest and wire into CI pipelines. See our software testing fundamentals guide for the broader pyramid.
Common mistakes
- Mutating state directly —
state.items.push(x)thensetItems(state.items)skips re-renders because the reference is unchanged. - Missing or unstable list keys — causes wrong row updates, lost input focus, and animation glitches.
- Infinite useEffect loops — setting state inside an effect without correct dependencies re-triggers forever.
- Fetching in useEffect without cleanup — race conditions when IDs change faster than responses return.
- Overusing global state — most UI state belongs in the component that owns the interaction.
- Premature memoization —
useCallbackeverywhere adds complexity without measurable gain.
Key takeaways
- React is declarative — you describe UI as a function of props and state; React updates the DOM efficiently.
- Function components + hooks (
useState,useEffect,useContext) are the standard model — learn them deeply before reaching for external libraries. - Props flow down, events flow up; lift state only as high as needed.
- Keep renders pure; put side effects in useEffect with correct dependencies and cleanup.
- Measure before optimizing; pair React with TypeScript, SSR, and solid testing for production apps.
Related reading
- TypeScript fundamentals — typing props, hooks, and API responses
- SSR vs CSR vs SSG vs ISR — where React renders in the stack
- JavaScript event loop — scheduling, batching, and INP
- CSS Flexbox and Grid — layout patterns for component UIs