Skip to content

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 them

JSX 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:

HTMLJSXWhy
classclassNameclass is reserved in JS
forhtmlForfor is reserved in JS
style="color: red"style={{ color: 'red' }}Object, not string
onclickonClickcamelCase events
tabindextabIndexcamelCase attributes
<br><br />Self-closing required
<!-- comment -->{/* comment */}JS expression syntax

2. Hooks Deep Dive ​

Rules of Hooks ​

  1. Only call at the top level (not inside loops, conditions, or nested functions)
  2. 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-render

useEffect ​

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 changes

Dependency array behavior:

tsx
useEffect(() => { ... });          // runs after EVERY render
useEffect(() => { ... }, []);      // runs ONCE after mount (like componentDidMount)
useEffect(() => { ... }, [a, b]);  // runs when a OR b changes

useEffect 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); // cleanup

useMemo 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 props

Custom 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 change

5. 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, useFormStatus for 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 bundle

8. 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 any

Always use strict: true. Turning it off loses most of TypeScript's value.

Frontend interview preparation reference.