Learn React and Next.js in a Single Post: Complete Tutorial From Components and Hooks to Server Components and Deployment

React is the dominant library for building user interfaces: you describe the UI as a function of state, and React figures out how to update the DOM. Next.js is the framework on top of React that adds routing, server rendering, and a deployment story — the default way most new React apps are built today. This single post teaches both in five stages, with hand-drawn diagrams and runnable snippets.

Learning Roadmap

React + Next.js Roadmap

The roadmap moves from components (Stage 1), through hooks (Stage 2), to state and data (Stage 3), routing and forms (Stage 4), and Next.js + shipping (Stage 5). You need solid JavaScript and HTML/CSS first.


Stage 1 — Components

JSX: HTML in JavaScript

JSX is a syntax extension that lets you write markup inside JavaScript. It compiles to React.createElement(...) calls:

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;   // {name} interpolates a JS expression
}

JSX looks like HTML but is JavaScript: class becomes className, for becomes htmlFor, styles are objects (style=), and you can embed any JS expression in {}.

Props: data flows down

A component receives data from its parent via props (read-only):

function Avatar({ user, size }) {
  return <img src={user.src} alt={user.name} width={size} />;
}

<Avatar user={currentUser} size={80} />

The component tree and one-way data flow

Component Tree: Props Down, Events Up

React apps are a tree of components. Data flows down via props; a child notifies its parent by calling a callback prop the parent passed down. This one-way flow is what makes React predictable — state lives in one place and trickles down.

Composition over inheritance

React has no inheritance. Reuse behavior by composing components (passing components as children or rendering slots) and by extracting custom hooks (Stage 2). “Components are the unit of reuse.”

function Card({ title, children }) {
  return <section><h2>{title}</h2>{children}</section>;
}
<Card title="Profile"><Avatar user={u} /></Card>

Stage 2 — Hooks

Hooks are functions that let function components “remember” things and do things. They’re the core of modern React (class components are legacy).

React Hooks

useState — local state

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

useState(initial) returns [value, setter]. Calling the setter schedules a re-render with the new value. State is per-component-instance.

Pitfall: setCount(count + 1) twice in a row doesn’t add 2 — React batches, and both see the same count. Use the updater form setCount(c => c + 1) when the next state depends on the previous.

useEffect — side effects

useEffect(() => {
  document.title = `Count: ${count}`;
  return () => { /* cleanup, runs before next effect + on unmount */ };
}, [count]);   // re-run only when count changes; [] = run once

useEffect runs after render. The dependency array controls when: [] once on mount, [a, b] when a or b change, no array = every render (usually a bug). Return a cleanup function to undo the effect (unsubscribe, clear a timer).

Pitfall: A missing dependency is the most common React bug — the effect closes over a stale value. The eslint-plugin-react-hooks exhaustive-deps rule catches this; enable it. The other classic: putting a subscription/fetch with no cleanup, leaking on unmount.

useRef — mutable value that doesn’t trigger re-render

const inputRef = useRef(null);
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Focus</button>

Use for DOM access, or for a value you want to mutate without causing a render (a timer id, a “has mounted” flag).

useMemo / useCallback — memoize expensive values/functions

const sorted = useMemo(() => expensiveSort(items), [items]);
const handleClick = useCallback(() => doThing(id), [id]);

These cache a value (useMemo) or a function (useCallback) across renders, so children that take it as a prop don’t re-render needlessly. Don’t memoize everything — memoization has its own cost; use it for genuinely expensive work or to stabilize referential equality for child memoization.

useContext — read shared state without prop drilling

const ThemeContext = createContext('light');
function App() { return <ThemeContext.Provider value="dark"><Page /></ThemeContext.Provider>; }
function Page() { const theme = useContext(ThemeContext); return <div className={theme} />; }

Context lets a value be read by any descendant without threading it through every component’s props. Use for themes, locale, auth user. For app-wide mutable state, a state library (below) is often better than context (context re-renders all consumers on any change).

Custom hooks — reuse logic

Extract stateful logic into a function whose name starts with use:

function useWindowWidth() {
  const [w, setW] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setW(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  return w;
}
function Navbar() { const w = useWindowWidth(); return w < 600 ? <MobileNav /> : <FullNav />; }

Custom hooks are how you share stateful logic across components without render props or higher-order components.


Stage 3 — State + Data

Local vs shared vs server state

Three different problems, three different tools:

  • Local UI state (a toggle, an input) → useState. Keep it in the component.
  • Shared client state (cart, theme, auth) → a state library. Don’t shove everything in context.
  • Server state (data from an API) → a data-fetching library, not a state library. Server state is cache, and cache invalidation is its own problem.

Client state libraries

  • Zustand — tiny, no boilerplate, the modern default for small/medium apps.
  • Redux Toolkit — structured, with a strict unidirectional flow and great devtools; for large apps with complex state interactions.
  • Context — built-in, fine for low-frequency updates (theme, locale); causes broad re-renders for frequent changes.
// Zustand
import { create } from 'zustand';
const useStore = create((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() { const inc = useStore((s) => s.inc); return <button onClick={inc}>+1</button>; }

Server state — TanStack Query

Fetching data in useEffect + useState is reinventing cache invalidation poorly. TanStack Query handles caching, deduplication, background refetching, and stale-while-revalidate:

import { useQuery } from '@tanstack/react-query';
function User({ id }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
  });
  if (isLoading) return <Spinner />;
  if (error) return <Error />;
  return <div>{data.name}</div>;
}

Pitfall: Treat server data as a cache, not as state you own. The canonical bugs — “it refetches too much / too little,” “stale data after a mutation” — are what TanStack Query (or SWR) exists to solve. Don’t hand-roll them.


Stage 4 — Routing + Forms

React Router (SPA)

<BrowserRouter>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/users/:id" element={<User />} />
    <Route path="*" element={<NotFound />} />
  </Routes>
</BrowserRouter>

function User() {
  const { id } = useParams();
  return <h1>User {id}</h1>;
}
<Link to="/users/42">View user 42</Link>

React Router maps the URL to components, with nested routes, params, and a <Link> that updates the URL without a full page reload. For a real app though, Next.js’s file-based router (below) is the modern default.

Forms

function ContactForm() {
  const [email, setEmail] = useState('');
  const submit = (e) => { e.preventDefault(); /* send */ };
  return (
    <form onSubmit={submit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
      <button>Send</button>
    </form>
  );
}

For anything beyond a field or two, use a form library (TanStack Form, React Hook Form) + a schema validator (Zod):

const schema = z.object({ email: z.string().email(), age: z.number().min(18) });

Zod schemas can also generate TypeScript types — one source of truth for validation and types.


Stage 5 — Next.js + Shipping

Why Next.js?

A plain React app (Vite SPA) renders everything in the browser — slow first paint, poor SEO, no data until JS loads. Next.js adds:

  • File-based routingapp/users/[id]/page.js/users/42.
  • Server rendering — pre-render pages on the server for fast first paint and SEO.
  • Server Components (RSC) — components that run on the server, can query the DB directly, ship zero JS.
  • API routes — backend endpoints in the same app.
  • Image/font optimization and a one-click deploy story (Vercel).

The render cycle (what React does under the hood)

Render -> Reconcile -> Commit Cycle

A state change → React renders (re-runs components to produce a virtual DOM) → reconciles (diffs new vs old via the fiber algorithm) → commits (applies the minimal set of DOM mutations) → the browser paints. The key performance idea: React does the least DOM work possible.

App Router and Server Components

app/
  layout.tsx       # wraps every page (server component by default)
  page.tsx         # the route's UI
  users/
    [id]/page.tsx  # dynamic route /users/:id
  api/route.ts     # backend endpoint
// app/users/[id]/page.tsx — a Server Component (runs on server, ships no JS)
export default async function UserPage({ params }: { params: { id: string } }) {
  const user = await db.user.findUnique({ where: { id: params.id } });
  return <h1>{user.name}</h1>;
}

Server Components can be async, fetch data directly, and don’t bloat the client bundle. Mark a component "use client" only when it needs interactivity (hooks, event handlers).

Pitfall: The biggest Next.js mistake is making everything a client component out of habit. Keep components on the server by default; reach for "use client" only at the leaf where interactivity lives. Smaller bundles, better SEO, faster first paint.

Testing

  • Vitest — unit tests (a function, a hook).
  • React Testing Library — component tests that query the DOM like a user (getByRole, getByText), not by implementation detail. The philosophy: “the more your tests resemble the way your software is used, the more confidence they give you.”
  • Playwright — end-to-end tests that drive a real browser.

Deploy

npx create-next-app@latest my-app
npm run dev          # http://localhost:3000
npm run build
# deploy: push to GitHub, import on Vercel, done. Or: dockerize + any host.

The ecosystem

React Ecosystem + Next.js

Concern Tool
Create app Vite (SPA), Next.js (full), Remix
Client state Zustand (small), Redux Toolkit (large), context (simple)
Server state TanStack Query, SWR
Routing Next.js App Router (default), React Router (SPA)
Forms TanStack Form / React Hook Form + Zod
Test Vitest, Testing Library, Playwright
Deploy Vercel, Netlify, or any Node/static host

Quick-Start Checklist

  1. Know JS + HTML/CSS first — React is JS; you can’t learn React on shaky JS.
  2. Start with create-next-app — it gives you routing, RSC, and a deploy path out of the box.
  3. Learn the 5 core hooks cold: useState, useEffect, useRef, useMemo, useContext.
  4. Enable eslint-plugin-react-hooks — it catches the stale-closure and missing-dep bugs automatically.
  5. Keep state local until you have a reason to lift it; prefer useState over a global store.
  6. Use TanStack Query for server data — don’t hand-roll fetching in useEffect.
  7. Extract custom hooks to reuse stateful logic; composition to reuse UI.
  8. Default to Server Components in Next.js; mark "use client" only where interactivity needs it.
  9. Test with React Testing Library — query by role/text, not by class names.
  10. Deploy on Vercel for the zero-config path; learn what npm run build produces.

Common Pitfalls

  • setX(x + 1) twice — batches to one update; use the updater setX(prev => prev + 1).
  • Missing effect dependency — stale closure; enable the hooks lint rule.
  • Fetching in useEffect — reinvents cache poorly; use TanStack Query.
  • Over-using context for frequent state — re-renders all consumers; use a state library.
  • Memoizing everythinguseMemo/useCallback have a cost; use for expensive work or referential stability.
  • Putting state in the wrong place — local vs shared vs server; each has its tool.
  • Making everything "use client" — bloats the bundle and loses RSC benefits; keep components server-side by default.
  • Testing implementation details (a class name, a state value) — tests break on refactor; test behavior with Testing Library.
  • Keying list items by array index — breaks when the list reorders; use a stable unique id.

Further Reading

React sits on top of the web fundamentals — these PyShine tutorials are its prerequisites and companions:


React’s core idea is small and durable: UI is a function of state, and data flows one way. The churn is in the ecosystem (state libraries, frameworks, RSC), but the mental model hasn’t changed in a decade. The five stages here — components, hooks, state+data, routing+forms, Next.js+shipping — cover everything from a button to a production server-rendered app. Spend a day per stage, build a real thing (a blog, a dashboard) in Next.js, write one test with Testing Library, and deploy it to Vercel. The concepts only stick once you’ve shipped a page that loads fast, fetches its own data, and survives a refactor.

Watch PyShine on YouTube

Contents