Most "learn React" plans fail for the same reason: they're lists of topics with no dates and nothing to ship.
So here's a different format. The calendar says there are about 11 weeks left in 2026, and that's enough time to go from "I know some React" to "I have a deployed full-stack app, a tested design system, an AI feature and a portfolio with case studies."
This is a plan in 7 stops, each with a date range, a checklist and one thing you must build before moving on.
A note before we start: this is an ambitious, opinionated plan, and it assumes you already know basic React (components, props, state). If you can only spare a few hours a week, don't skip stops. Cut the scope inside each one. There's a section on that near the end.
The plan at a glance
| Stop | Dates | Focus | You ship |
|---|---|---|---|
| 01 | Oct 14 – 20 | Foundations | One mini-UI with modern CSS only |
| 02 | Oct 21 – Nov 3 | React 19 deep dive | A small React 19 form app |
| 03 | Nov 4 – 17 | Full-stack Next.js | A deployed CRUD app with auth |
| 04 | Nov 18 – 24 | State, data and forms | A typed data layer with live updates |
| 05 | Nov 25 – Dec 8 | UI and design system | 10 documented, accessible components |
| 06 | Dec 9 – 18 | Test, speed and CI | E2E tests, a CI pipeline, error monitoring |
| 07 | Dec 19 – 31 | AI feature and ship | A streaming AI UI and a published portfolio |
The order is deliberate. Each stop produces something the next one needs: the form app from stop 2 becomes the starting point for stop 3, the components from stop 5 get tested in stop 6, and so on.
Stop 1 (Oct 14 – 20): Lock the foundations
The checklist
- Audit your gaps in JS, TypeScript and modern CSS
- TypeScript strict mode: generics, narrowing, utility types
- CSS nesting,
@layer, container queries - Build one mini-UI with modern CSS only
- Set up pnpm, ESLint, Prettier and Husky
One week is short, so don't try to "learn TypeScript." Instead, find your gaps. Open a few components you've written and ask where you used any, where you cast with as, and where you copied a type without understanding it.
TypeScript: the three ideas that pay off in React
Generics show up the moment you write a reusable component or hook:
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
});
// ...
return [value, setValue] as const;
}
Narrowing is how you handle API results safely:
type Result =
| { status: "success"; data: User }
| { status: "error"; message: string };
function render(result: Result) {
if (result.status === "error") return result.message; // narrowed here
return result.data.name; // and here
}
Utility types (Pick, Omit, Partial, ReturnType) save you from retyping the same shape:
type UserPreview = Pick<User, "id" | "name" | "avatarUrl">;
type UserDraft = Partial<Omit<User, "id">>;
Turn on "strict": true in tsconfig.json if you haven't. It's uncomfortable for a day and valuable afterward.
Modern CSS you can use today
Native nesting, cascade layers and container queries remove a lot of reasons to reach for a preprocessor or a utility framework for small components:
@layer base, components;
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
padding: 1rem;
border-radius: 0.75rem;
& h3 {
margin: 0;
}
@container (min-width: 480px) {
display: grid;
grid-template-columns: 160px 1fr;
gap: 1rem;
}
}
}
The project for this stop is one mini-UI using only modern CSS: a pricing card, a profile card or a dashboard tile. No Tailwind, no component library. The point is to find out what you can do without them.
Tooling
Set up the boring stuff now so it doesn't interrupt you later:
pnpm create vite my-app --template react-ts
cd my-app
pnpm add -D eslint prettier husky lint-staged
pnpm exec husky init
A pre-commit hook that runs lint and format on staged files will catch most embarrassing commits before your teammates (or recruiters) see them.
Done when: strict TS compiles cleanly, your mini-UI works at three container widths, and committing triggers lint and format.
Stop 2 (Oct 21 – Nov 3): React 19 deep dive
The checklist
- Actions,
useActionState,useOptimistic - The
use()hook and Suspense data loading - Enable React Compiler in one project
- Refactor an old app: drop
memoclutter - Ship a small React 19 form app
This stop gets two weeks because it changes how you write React day to day.
Actions and useActionState
Before React 19, a form that submits to an API meant hand-rolling isLoading, error and data state. Now an async function can be the form's action, and React tracks the pending state for you:
import { useActionState } from "react";
type State = { ok: boolean; message: string } | null;
async function subscribe(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get("email") ?? "");
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
return res.ok
? { ok: true, message: "Subscribed!" }
: { ok: false, message: "Something went wrong" };
}
export function Newsletter() {
const [state, action, isPending] = useActionState(subscribe, null);
return (
<form action={action}>
<input name="email" type="email" required />
<button disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state && <p role="status">{state.message}</p>}
</form>
);
}
useOptimistic
Optimistic UI means showing the result before the server confirms it. useOptimistic gives you a temporary value that automatically reverts to the real state when the action finishes:
import { useOptimistic } from "react";
function TodoList({ todos, addTodo }: Props) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current, newTitle: string) => [
...current,
{ id: crypto.randomUUID(), title: newTitle, pending: true },
]
);
async function formAction(formData: FormData) {
const title = String(formData.get("title"));
addOptimistic(title);
await addTodo(title); // if this throws, the optimistic item disappears
}
return (
<form action={formAction}>
<input name="title" />
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
{t.title}
</li>
))}
</ul>
</form>
);
}
use() and Suspense
use() reads a promise (or context) during render and suspends until it resolves. The important rule: the promise must be stable across renders. Creating a new fetch() inside the component body creates a new promise every render. Pass it down from a parent, a framework loader, or a cache:
import { use, Suspense } from "react";
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise);
return comments.map((c) => <p key={c.id}>{c.text}</p>);
}
export function Post({ commentsPromise }: Props) {
return (
<Suspense fallback={<p>Loading comments...</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
React Compiler and the memo cleanup
The React Compiler memoizes components and values at build time. If you've sprinkled useMemo, useCallback and React.memo through a codebase "just in case," the compiler makes most of it unnecessary.
With Vite it's a Babel plugin:
pnpm add -D babel-plugin-react-compiler
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: { plugins: ["babel-plugin-react-compiler"] },
}),
],
});
Setup differs by framework and version, so follow the official React Compiler docs for yours.
The refactor exercise from the roadmap is the best part of this stop:
- Pick an old project with lots of manual memoization
- Profile it with React DevTools before changing anything
- Enable the compiler
- Remove manual memoization in small batches
- Profile again and compare
Remove useMemo/useCallback where it's only there for performance. Keep it where a value's identity genuinely matters (for example a dependency of an effect that shouldn't re-run). And measure. Don't assume.
Done when: you've shipped a small form app using actions and optimistic updates, and you can explain why you kept or removed each manual memo.
Stop 3 (Nov 4 – 17): Full-stack Next.js
The checklist
- Server Components and Server Functions
- CRUD app: Next.js + Postgres/Prisma
- Auth with Auth.js or Clerk, plus middleware
- Streaming, caching and revalidation
- Deploy on Vercel with preview URLs
This is the biggest conceptual jump in the plan. Give it the time.
Server Components: data fetching without an API layer
A Server Component runs on the server, so it can talk to the database directly. Nothing from it ships to the browser except the rendered output:
// app/posts/page.tsx (Server Component by default)
import { prisma } from "@/lib/prisma";
export default async function PostsPage() {
const posts = await prisma.post.findMany({
orderBy: { createdAt: "desc" },
});
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
Server Functions for mutations
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
const Schema = z.object({ title: z.string().min(1).max(120) });
export async function createPost(formData: FormData) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
const parsed = Schema.safeParse({ title: formData.get("title") });
if (!parsed.success) return { error: "Invalid title" };
await prisma.post.create({
data: { title: parsed.data.title, authorId: session.user.id },
});
revalidatePath("/posts");
}
Treat every Server Function like a public HTTP endpoint, because that's what it is. It needs its own authentication check and input validation. Hiding a button in the UI is not authorization. This is one of the most common mistakes in this model, and the roadmap's "auth + middleware" item exists partly to drive the point home.
Auth
Both Auth.js and Clerk are reasonable. A rough rule: Auth.js if you want to own the data and the flow, Clerk if you'd rather not build sign-in, sessions and user management yourself. Whichever you pick, use middleware (called proxy in newer Next.js versions, so check your version's docs) for coarse route protection, and still verify the session inside your data access code.
Streaming, caching and revalidation
Three questions to answer for every page:
-
Can the shell render immediately? Use
loading.tsxor<Suspense>so slow data doesn't block the whole page. - How fresh does this data need to be? Per-user data usually shouldn't be cached shared. A blog list can be.
-
What invalidates it? Tie mutations to
revalidatePathor tag-based revalidation.
Caching defaults have changed between Next.js versions, so don't rely on tutorials older than your installed version. Read the current caching docs, then test by actually mutating data and watching what updates.
Deploy early
Push to GitHub, connect to Vercel, and every pull request gets a preview URL. Do this on day one of this stop, not day fourteen. Deploy problems are easier to fix when the app is tiny.
Done when: you have a deployed CRUD app with sign-in, where logged-out users can't mutate data even if they call the Server Function directly.
Stop 4 (Nov 18 – 24): State, data and forms
The checklist
- TanStack Query: cache, mutations, prefetch
- Zustand or Redux Toolkit for UI state
- React Hook Form + Zod validation
- Typed API layer: tRPC or OpenAPI
- Live updates via SSE / WebSockets
One week, so focus on the mental model: server state and client state are different things.
| State type | Examples | Tool |
|---|---|---|
| Server state | Lists, profiles, anything from an API | TanStack Query (or your framework's data layer) |
| Client UI state | Open modal, selected tab, filters |
useState, then Zustand or Redux Toolkit if shared widely |
| Form state | Inputs, errors, dirty/touched | React Hook Form |
| Validation | Shape and rules of data | Zod |
TanStack Query: cache and mutations
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function Todos() {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: ["todos"],
queryFn: () => fetch("/api/todos").then((r) => r.json()),
});
const addTodo = useMutation({
mutationFn: (title: string) =>
fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ title }),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>Something went wrong</p>;
// render data, call addTodo.mutate(...)
}
Spend time on queryKey design and invalidation. Most TanStack Query bugs are cache-key bugs.
Forms: React Hook Form + Zod
Define the schema once, then use it for both types and validation:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email("Enter a valid email"),
age: z.coerce.number().int().min(18, "Must be 18 or older"),
});
type FormValues = z.infer<typeof Schema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(Schema) });
return (
<form onSubmit={handleSubmit(async (values) => { /* send */ })}>
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="number" {...register("age")} />
{errors.age && <p role="alert">{errors.age.message}</p>}
<button disabled={isSubmitting}>Sign up</button>
</form>
);
}
Remember that client-side validation is for UX. The server must validate again with the same schema.
Typed API layer
- tRPC gives end-to-end type safety with no schema file, but works best when your frontend and backend are both TypeScript and in the same repo or workspace.
- OpenAPI with a generated client is a better fit when the backend is a different language or owned by another team.
Pick whichever matches your situation, and try the other briefly so you know the trade-off.
Live updates
For one-way updates (notifications, progress, a live feed), Server-Sent Events are simpler than WebSockets:
useEffect(() => {
const source = new EventSource("/api/notifications");
source.onmessage = (e) => {
const event = JSON.parse(e.data);
queryClient.setQueryData(["notifications"], (old = []) => [event, ...old]);
};
return () => source.close();
}, [queryClient]);
Use WebSockets when you need two-way, low-latency communication such as chat or collaborative editing.
Done when: your app from stop 3 has a typed data layer, a validated form, and at least one feature that updates live without a refresh.
Stop 5 (Nov 25 – Dec 8): UI and design system
The checklist
- Tailwind v4 + design tokens
- 10 components with Radix / shadcn/ui
- Document them in Storybook
- Pass an axe audit: keyboard, ARIA, contrast
- Dark mode, motion and i18n
Design tokens in Tailwind v4
Tailwind v4 moved configuration into CSS. Your tokens live in @theme:
@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.62 0.19 259);
--color-surface: oklch(0.99 0 0);
--radius-card: 0.75rem;
--font-sans: "Inter", system-ui, sans-serif;
}
Then bg-brand-500 and rounded-card just work. The benefit of tokens is that a rebrand or a dark theme becomes a change in one file.
Ten components, not fifty
Pick ten you'd genuinely reuse: Button, Input, Select, Checkbox, Dialog, Dropdown menu, Tabs, Toast, Card, Skeleton. Build them on Radix primitives or copy them in via shadcn/ui and then actually read and adjust the code. The value of shadcn/ui is that you own the components, so treat them as your code.
Storybook as documentation
For each component, write stories for its meaningful states: default, disabled, loading, error, long text and dark mode. If you can't think of a state to document, you probably don't understand the component's contract yet.
Accessibility audit
Run the audit in this order:
- Keyboard only: unplug the mouse. Can you reach and operate everything? Is focus visible? Does a dialog trap and restore focus?
-
Automated: run axe (the browser extension,
@axe-core/playwright, or Storybook's a11y addon). It finds a portion of issues, not all. - Contrast: check text and UI against WCAG AA ratios.
- Screen reader spot check: at least try VoiceOver or NVDA on one flow.
Dark mode, motion and i18n
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Respect prefers-color-scheme and prefers-reduced-motion by default, and use a library such as next-intl or react-i18next to pull strings out of components early. Retrofitting i18n into a finished UI is painful.
Done when: you have ten documented components that pass keyboard navigation and an axe scan, in both light and dark themes.
Stop 6 (Dec 9 – 18): Test, speed and CI
The checklist
- Vitest + React Testing Library for core flows
- Playwright E2E for 3 user journeys
- INP under 200ms, LCP under 2.5s
- GitHub Actions: lint, test, deploy
- Sentry error monitoring
Unit and component tests
Test what the user experiences, not implementation details:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "./SignupForm";
test("shows a validation error for an invalid email", async () => {
const user = userEvent.setup();
render(<SignupForm />);
await user.type(screen.getByRole("textbox", { name: /email/i }), "nope");
await user.click(screen.getByRole("button", { name: /sign up/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/valid email/i);
});
Selecting by role and accessible name pushes you toward accessible markup as a side effect.
Three E2E journeys
Don't try to cover everything. Pick the three flows that would hurt most if they broke, for example sign in, create a post, and checkout (or whatever your app's core action is):
import { test, expect } from "@playwright/test";
test("signed-in user can create a post", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill(process.env.E2E_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByRole("link", { name: "New post" }).click();
await page.getByLabel("Title").fill("Hello from Playwright");
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Hello from Playwright")).toBeVisible();
});
Performance targets
The roadmap's targets match Google's "good" thresholds for Core Web Vitals: LCP at or under 2.5 seconds and INP under 200 milliseconds (at the 75th percentile of real users). A few notes:
- Lighthouse gives you lab data. It's useful for debugging but not the same as what your users experience.
- INP measures responsiveness across the whole visit, so it's usually hurt by long tasks from heavy JavaScript. Look at the Performance panel for long tasks after interactions.
- LCP is most often an image or hero text problem. Check what the LCP element actually is before optimizing anything.
You can capture field data from your own app with the web-vitals library:
import { onINP, onLCP } from "web-vitals";
onINP((metric) => console.log("INP", metric.value));
onLCP((metric) => console.log("LCP", metric.value));
CI with GitHub Actions
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm tsc --noEmit
- run: pnpm test run
Action and Node versions move over time, so check the current ones when you copy this. Add the Playwright job after the basics work.
Error monitoring
Add Sentry (or an equivalent) and deliberately trigger a test error in production. The first time you see a real stack trace with source maps is when monitoring clicks.
Done when: a pull request that breaks a core flow fails CI, and an error in production shows up in your monitoring tool.
Stop 7 (Dec 19 – 31): AI feature and ship
The checklist
- Streaming AI chat UI with Vercel AI SDK
- Try tool-calling and MCP in an app
- Publish portfolio with 3 case studies
- Write on DEV.to and post an X thread
- Plan 2027: pick 1 deep-dive track
Build the AI feature with the right expectations
Most of the engineering in an AI feature is UX, not the model call. A server route that streams a response looks roughly like this with the Vercel AI SDK:
// app/api/chat/route.ts
import { streamText, convertToModelMessages, type UIMessage } from "ai";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: "YOUR_MODEL_ID", // see the SDK docs for supported models
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
The SDK's API has changed between major versions, so follow the docs for the version you install rather than copying older examples.
The frontend questions that actually matter:
- What does the user see while the model is thinking?
- What happens when a response is wrong, slow or fails halfway?
- Can the user stop a response, retry, or edit their message?
- Where does it stop being trustworthy, and is that visible to the user?
Treat model output as untrusted input. Don't render it as raw HTML, and don't let it call tools with side effects (sending email, deleting data) without validation and user confirmation.
Tool-calling and MCP
Tool-calling lets the model request that your code run a function with structured arguments, such as "look up this order" or "search these docs." MCP (Model Context Protocol) standardizes how apps expose tools and data to models. For this stop, one small experiment is enough: give your app a read-only tool and watch how the model decides when to use it. Keep write actions behind explicit confirmation.
Ship the portfolio and write about it
This is the step people skip, and it's the one that makes the other six count.
A case study doesn't need to be long. For each of three projects, cover:
- The problem: what it does and who it's for
- A decision you made: and what you rejected
- Something that went wrong: and how you found the cause
- What you'd change: being honest builds more trust than being polished
Then write about it on DEV.to, post a short X thread, and link the repo and live demo. Use only results you actually measured. Don't claim a Lighthouse score or a speedup you didn't verify.
Plan 2027
Finish with a decision: pick one deep-dive track for the new year, such as performance, design systems, full-stack architecture, testing, or AI engineering. Breadth got you through this plan. Depth is what makes you recognizable.
Done when: your portfolio is live with three case studies, you've published at least one write-up, and you can say in one sentence what you're going deeper on next.
Common mistakes with a plan like this
- Tutorial hopping instead of building. Every stop has a deliverable for a reason. If you haven't shipped something, you haven't finished the stop.
- Skipping foundations to get to the exciting stops. Stop 7 on a weak stop 1 gives you a demo that falls apart.
- Letting one stop eat the schedule. Stop 3 will tempt you to extend it. Don't. Ship the smaller version.
- Copying code without checking versions. React, Next.js, the AI SDK and Tailwind have all shifted APIs in recent versions. Always check the docs for what you've installed.
- Never writing it down. Notes become blog posts, and blog posts become the portfolio.
If you can't do 11 full weeks
Keep all seven stops but shrink the scope inside each:
| If you have... | Do this |
|---|---|
| About 5 hours/week | Do one mini-project per stop, skip Storybook and i18n, and extend the schedule into January |
| About 10 hours/week | Follow the plan, but cut stop 4's live updates and stop 7's MCP experiment |
| Full-time availability | Follow the plan as written, and add a second project at stops 3 and 5 |
A plan you finish at 70% beats one you abandon in week three.
Key takeaways
- A roadmap is only useful if every stop ends with something shipped.
- Learn React 19's model (actions,
use(), the compiler) before layering a framework on top. - Treat Server Functions like public endpoints: authenticate and validate inside them.
- Separate server state, client state and form state, and use the right tool for each.
- Accessibility, tests and CI are part of building the thing, not a final polish step.
- AI features are mostly a UX and safety problem. Design the failure states first.
What would you change in this plan? If you've been through some of these stops, which one took longer than expected, and what would you cut or add? And if you're starting it this week, tell me which stop you're most worried about. I'd like to hear how it goes.

Top comments (0)