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.

React Roadmap 2027 infographic showing 7 dated stops from Oct 14 to Dec 31

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 memo clutter
  • 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:

  1. Pick an old project with lots of manual memoization
  2. Profile it with React DevTools before changing anything
  3. Enable the compiler
  4. Remove manual memoization in small batches
  5. 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:

  1. Can the shell render immediately? Use loading.tsx or <Suspense> so slow data doesn't block the whole page.
  2. How fresh does this data need to be? Per-user data usually shouldn't be cached shared. A blog list can be.
  3. What invalidates it? Tie mutations to revalidatePath or 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:

Related posts