17 - React Project Fundamentals ​
Everything you should know cold about how a React project works under the hood.
1. How React Renders ​
The Render Cycle ​
Trigger → Render → Commit
1. TRIGGER: Something causes a re-render
- Initial mount (createRoot + render)
- State change (setState / dispatch)
- Parent re-render (props may have changed)
- Context value change
2. RENDER: React calls your component function
- Executes the function body
- Evaluates JSX → creates React Elements (plain JS objects)
- Compares new elements with previous elements (reconciliation)
- Calculates what DOM changes are needed
- This phase is PURE — no side effects, no DOM mutations
3. COMMIT: React applies changes to the DOM
- Inserts/updates/removes DOM nodes
- Runs useLayoutEffect callbacks (synchronous, before paint)
- Browser paints
- Runs useEffect callbacks (asynchronous, after paint)React Element vs Component vs Instance ​
tsx
// COMPONENT: A function that returns JSX
function Button({ label }: { label: string }) {
return <button>{label}</button>;
}
// ELEMENT: What JSX compiles to — a plain JS object describing the UI
// <Button label="Click" /> compiles to:
{
type: Button, // reference to the component function
props: { label: "Click" },
key: null,
ref: null,
}
// INSTANCE: React's internal representation (fiber node)
// Holds: state, effects, refs, position in tree
// You never create instances — React manages themJSX is NOT HTML ​
tsx
// JSX is syntactic sugar for React.createElement()
<div className="card">
<h1>{title}</h1>
<Button onClick={handleClick} />
</div>
// Compiles to (modern JSX transform — no React import needed):
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';
_jsxs('div', {
className: 'card',
children: [
_jsx('h1', { children: title }),
_jsx(Button, { onClick: handleClick }),
],
});Key JSX differences from HTML:
| HTML | JSX | Why |
|---|---|---|
class | className | class is reserved in JS |
for | htmlFor | for is reserved in JS |
style="color: red" | style={{ color: 'red' }} | Object, not string |
onclick | onClick | camelCase events |
tabindex | tabIndex | camelCase attributes |
<br> | <br /> | Self-closing required |
<!-- comment --> | {/* comment */} | JS expression syntax |
2. Hooks Deep Dive ​
Rules of Hooks ​
- Only call at the top level (not inside loops, conditions, or nested functions)
- Only call from React functions (components or custom hooks)
Why top level? Hooks rely on call ORDER. React stores hooks as a linked list on the fiber node. If you skip a hook conditionally, every subsequent hook reads the wrong slot.
tsx
// BAD: Conditional hook — order changes between renders
function Component({ show }) {
if (show) {
const [val, setVal] = useState(0); // hook 1 (sometimes)
}
const [name, setName] = useState(''); // hook 1 or 2 (depends on `show`)
// React can't track which state belongs where
}
// GOOD: Always call, conditionally USE
function Component({ show }) {
const [val, setVal] = useState(0); // always hook 1
const [name, setName] = useState(''); // always hook 2
// Only use `val` if `show` is true
}useState ​
tsx
// Basic
const [count, setCount] = useState(0);
// Lazy initialization (expensive initial value)
const [data, setData] = useState(() => {
return computeExpensiveInitialValue(); // only runs on first render
});
// Updater function (when new state depends on previous)
setCount(prev => prev + 1); // always uses latest state (no stale closure)
// Gotcha: state updates are BATCHED
function handleClick() {
setCount(1); // queued
setName('Alice'); // queued
setAge(25); // queued
// React re-renders ONCE with all three updates (React 18+)
}
// Gotcha: objects must be replaced, not mutated
setUser({ ...user, name: 'New Name' }); // new object → re-render
// NOT: user.name = 'New Name'; setUser(user); → same ref → no re-renderuseEffect ​
tsx
// Runs AFTER render + paint (asynchronous)
useEffect(() => {
// Side effect code
document.title = `Count: ${count}`;
// Cleanup function — runs before next effect AND on unmount
return () => {
// Cancel subscriptions, clear timers, abort fetches
};
}, [count]); // only re-run when count changesDependency array behavior:
tsx
useEffect(() => { ... }); // runs after EVERY render
useEffect(() => { ... }, []); // runs ONCE after mount (like componentDidMount)
useEffect(() => { ... }, [a, b]); // runs when a OR b changesuseEffect is NOT componentDidMount. It runs AFTER paint (async). Use useLayoutEffect if you need to run BEFORE paint (sync, blocks rendering).
useLayoutEffect ​
tsx
// Runs AFTER DOM mutations but BEFORE the browser paints
// Use for: measuring DOM elements, preventing visual flickers
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setPosition({ top: rect.top, left: rect.left });
// If this was useEffect, user would see a flash of wrong position
}, []);useRef ​
tsx
// Two use cases:
// 1. DOM reference
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />
inputRef.current?.focus(); // access DOM node
// 2. Mutable value that persists across renders (doesn't trigger re-render)
const renderCount = useRef(0);
renderCount.current += 1; // mutating .current does NOT cause re-render
const intervalId = useRef<number | null>(null);
intervalId.current = setInterval(...);
clearInterval(intervalId.current); // cleanupuseMemo and useCallback ​
tsx
// useMemo: cache a COMPUTED VALUE
const expensiveResult = useMemo(
() => computeExpensiveValue(data),
[data] // only recompute when data changes
);
// useCallback: cache a FUNCTION REFERENCE
const handleClick = useCallback(
() => { setCount(prev => prev + 1); },
[] // stable reference — doesn't change between renders
);
// When to use:
// useMemo → expensive computation, or object/array passed to memoized child
// useCallback → function passed as prop to React.memo component
// Don't use for → trivial calculations, functions not passed as propsCustom Hooks ​
tsx
// Extract reusable stateful logic into a custom hook
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');3. Component Patterns ​
Controlled vs Uncontrolled Components ​
tsx
// CONTROLLED: React state drives the input value
function Controlled() {
const [value, setValue] = useState('');
return <input value={value} onChange={e => setValue(e.target.value)} />;
// React is the "source of truth"
}
// UNCONTROLLED: DOM holds the value, access via ref
function Uncontrolled() {
const ref = useRef<HTMLInputElement>(null);
const handleSubmit = () => console.log(ref.current?.value);
return <input ref={ref} defaultValue="" />;
// DOM is the "source of truth"
}When to use which:
- Controlled: validation on every keystroke, conditional logic, form libraries
- Uncontrolled: simple forms, file inputs, integrating non-React code
Composition vs Inheritance ​
tsx
// React NEVER uses inheritance. Always composition.
// Composition via children
function Card({ children }: { children: React.ReactNode }) {
return <div className="card">{children}</div>;
}
// Composition via slots (named props)
function Dialog({ title, body, footer }: DialogProps) {
return (
<div className="dialog">
<header>{title}</header>
<main>{body}</main>
<footer>{footer}</footer>
</div>
);
}
// Composition via render props
function DataFetcher<T>({ url, children }: { url: string; children: (data: T) => ReactNode }) {
const { data, isLoading } = useFetch<T>(url);
if (isLoading) return <Spinner />;
return <>{children(data!)}</>;
}
<DataFetcher url="/api/users">
{(users) => <UserList users={users} />}
</DataFetcher>Higher-Order Components (HOC) ​
tsx
// A function that takes a component and returns an enhanced component
function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) {
return function AuthenticatedComponent(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} />;
};
}
const ProtectedDashboard = withAuth(Dashboard);HOCs are mostly replaced by custom hooks now. But know the pattern — you'll see it in older codebases.
4. Keys and Lists ​
tsx
// Keys tell React which items changed, were added, or removed
{items.map(item => (
<ListItem key={item.id} item={item} />
// key must be:
// 1. Unique among siblings
// 2. Stable (same item = same key across renders)
// 3. NOT the array index (breaks on reorder/delete)
))}Why index as key is bad:
Items: [A, B, C] with keys [0, 1, 2]
Delete B: [A, C] with keys [0, 1]
React sees:
key=0: was A, still A → no change
key=1: was B, now C → UPDATE content (wrong! B was deleted, not updated)
key=2: was C, gone → DELETE
Result: C gets B's state. Bug.
With proper keys:
key="a": A → no change
key="b": B → DELETE (correct!)
key="c": C → no change5. Error Boundaries ​
tsx
// Class component (only way to catch render errors — no hook equivalent)
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('Error:', error, info.componentStack);
// Report to error tracking service
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<RiskyComponent />
</ErrorBoundary>What error boundaries DON'T catch:
- Event handler errors (use try/catch)
- Async code (promises, setTimeout)
- Server-side rendering errors
- Errors in the error boundary itself
6. React 18 & 19 Features to Know ​
React 18 ​
- Automatic batching: All setState calls batched (even in setTimeout/promises)
- Concurrent rendering:
startTransition,useDeferredValue - Suspense for data fetching:
<Suspense>works with lazy and data libraries - useId: Generates unique IDs that are stable across server/client (for SSR)
- useSyncExternalStore: Safe way to read external stores in concurrent mode
React 19 ​
- Server Components: Components that run only on the server (no JS shipped)
- Actions:
useActionState,useFormStatusfor form handling use()hook: Reads promises and context inline (suspends if pending)- ref as prop: No more
forwardRef— ref is a regular prop - Document metadata:
<title>,<meta>in components hoisted to<head> - Improved error reporting: Better error messages with component stacks
tsx
// React 19: use() hook
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until resolved
return <h1>{user.name}</h1>;
}
// React 19: ref as regular prop (no forwardRef needed)
function Input({ ref, ...props }: { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// React 19: useActionState for forms
function LoginForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData) => {
const result = await login(formData);
if (result.error) return { error: result.error };
redirect('/dashboard');
},
{ error: null }
);
return (
<form action={formAction}>
<input name="email" type="email" />
<button disabled={isPending}>
{isPending ? 'Logging in...' : 'Log in'}
</button>
{state.error && <p>{state.error}</p>}
</form>
);
}7. package.json — Key Fields ​
json
{
"name": "my-app",
"version": "1.0.0",
"private": true, // prevent accidental npm publish
"type": "module", // use ESM imports (not CommonJS)
"scripts": {
"dev": "vite", // start dev server
"build": "tsc -b && vite build", // type check then build
"preview": "vite preview", // preview production build locally
"test": "vitest", // run tests in watch mode
"test:run": "vitest run", // run tests once (CI)
"lint": "eslint .", // lint all files
"format": "prettier --write ." // format all files
},
"dependencies": { // shipped to users
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": { // only for development
"typescript": "~5.7.0",
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.3.0",
"vitest": "^2.1.0",
"eslint": "^9.0.0",
"prettier": "^3.4.0"
}
}Version ranges:
"^19.0.0" → >=19.0.0, <20.0.0 (allows minor + patch updates)
"~5.7.0" → >=5.7.0, <5.8.0 (allows only patch updates)
"19.0.0" → exactly 19.0.0 (locked)dependencies vs devDependencies ​
dependencies: Code that runs in the user's browser (React, Zustand, Axios)
devDependencies: Tools for development only (Vite, ESLint, Vitest, TypeScript)
NOT included in the production bundle8. tsconfig.json — Key Fields ​
json
{
"compilerOptions": {
// ---- Output ----
"target": "ES2020", // JS version to compile to
"module": "ESNext", // module system (ESM)
"jsx": "react-jsx", // modern JSX transform (no React import needed)
"noEmit": true, // Vite handles compilation, TS only type-checks
// ---- Strictness ----
"strict": true, // enable ALL strict checks
"noUnusedLocals": true, // error on unused variables
"noUnusedParameters": true, // error on unused function params
"noFallthroughCasesInSwitch": true, // require break in switch cases
"noUncheckedIndexedAccess": true, // array[0] is T | undefined
// ---- Module Resolution ----
"moduleResolution": "bundler", // let Vite handle resolution
"allowImportingTsExtensions": true,
"isolatedModules": true, // required for Vite (each file = isolated module)
// ---- Path Aliases ----
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}What "strict: true" enables:
strictNullChecks → null/undefined are separate types (most important)
noImplicitAny → must annotate when TS can't infer
strictFunctionTypes → stricter function parameter checks
strictBindCallApply → type-check bind, call, apply
strictPropertyInitialization → class properties must be initialized
alwaysStrict → emit "use strict" in every file
noImplicitThis → error on ambiguous `this`
useUnknownInCatchVariables → catch variable is unknown, not anyAlways use strict: true. Turning it off loses most of TypeScript's value.