Phase 2: React Ecosystem — Routing, State Management, Forms & Data Fetching

Welcome to Phase 2! In Phase 1, you mastered React's core model — components, props, state, and hooks. But a real application needs more than that. It needs multiple pages, shared global state, robust forms, and efficient data fetching.
This phase introduces the React ecosystem — the battle-tested libraries that React developers reach for on every project.
Time commitment: 5–7 days, 1–2 hours daily
Prerequisites: Phase 1: React Fundamentals
What You'll Learn
By the end of Phase 2, you'll be able to:
✅ Build multi-page apps with React Router v6
✅ Create protected routes and nested layouts
✅ Share state globally with Context API and Zustand
✅ Handle forms with validation using React Hook Form + Zod
✅ Fetch and cache server data with TanStack Query
✅ Build UIs fast with Tailwind CSS and shadcn/ui
✅ Handle loading, error, and empty states consistently
The React Ecosystem at a Glance
Each library solves a distinct problem. You'll reach for all of them regularly.
Client-Side Routing with React Router v6
A single-page application (SPA) needs to simulate multiple pages without full page reloads. React Router handles this by mapping URL paths to components.
Installation
npm install react-router-domBasic Setup
Wrap your app in BrowserRouter and define routes:
// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);// App.jsx
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Home from './pages/Home';
import Blog from './pages/Blog';
import Post from './pages/Post';
import About from './pages/About';
import NotFound from './pages/NotFound';
export default function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="blog" element={<Blog />} />
<Route path="blog/:slug" element={<Post />} />
<Route path="about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}Navigation and Links
import { Link, NavLink, useNavigate } from 'react-router-dom';
function Navigation() {
const navigate = useNavigate();
return (
<nav>
{/* Link — basic navigation */}
<Link to="/">Home</Link>
{/* NavLink — adds 'active' class when route matches */}
<NavLink
to="/blog"
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
>
Blog
</NavLink>
{/* Programmatic navigation */}
<button onClick={() => navigate('/about')}>About</button>
<button onClick={() => navigate(-1)}>Go Back</button>
</nav>
);
}Nested Routes and Layouts
React Router v6's killer feature: nested routes share layouts automatically.
// components/Layout.jsx
import { Outlet, Link } from 'react-router-dom';
export default function Layout() {
return (
<div>
<header>
<Link to="/">MyApp</Link>
<nav>
<Link to="/blog">Blog</Link>
<Link to="/about">About</Link>
</nav>
</header>
<main>
<Outlet /> {/* Child route renders here */}
</main>
<footer>© 2026 MyApp</footer>
</div>
);
}// pages/Blog.jsx — with nested routes
import { Routes, Route, Outlet } from 'react-router-dom';
export default function Blog() {
return (
<div className="blog-layout">
<aside>
<h2>Categories</h2>
{/* sidebar */}
</aside>
<div className="blog-content">
<Outlet /> {/* PostList or Post renders here */}
</div>
</div>
);
}Dynamic Route Parameters
import { useParams } from 'react-router-dom';
function Post() {
const { slug } = useParams(); // Reads :slug from the URL
return <h1>Post: {slug}</h1>;
}Protected Routes
Wrap private routes in a guard component:
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './hooks/useAuth';
function ProtectedRoute() {
const { user, isLoading } = useAuth();
if (isLoading) return <p>Loading...</p>;
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
// In App.jsx
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Route>Query Strings
import { useSearchParams } from 'react-router-dom';
function BlogList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page') ?? '1');
const tag = searchParams.get('tag') ?? '';
return (
<div>
<input
value={tag}
onChange={e => setSearchParams({ tag: e.target.value, page: '1' })}
placeholder="Filter by tag..."
/>
<p>Page {page} — Tag: {tag || 'all'}</p>
</div>
);
}Global State Management
When Do You Need Global State?
Not every app needs global state. Start without it and add it when you feel the pain:
- You're passing the same prop through 3+ component levels (prop drilling)
- Multiple unrelated components need the same data (user session, theme, cart)
- State changes in one part of the app need to affect another part
Start simple. Only upgrade when you feel the pain.
Context API
The React built-in. Great for low-frequency updates (theme, auth, locale).
import { createContext, useContext, useState } from 'react';
// 1. Create the context
const ThemeContext = createContext(null);
// 2. Create a provider component
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// 3. Create a custom hook for clean consumption
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used inside ThemeProvider');
return context;
}
// 4. Use in any component
function ThemeToggle() {
const { theme, toggle } = useTheme();
return (
<button onClick={toggle}>
Current theme: {theme}
</button>
);
}
// 5. Wrap your app
function App() {
return (
<ThemeProvider>
<Router>
{/* ... */}
</Router>
</ThemeProvider>
);
}Context limitations: Every component that consumes a context re-renders when any value in the context changes. For frequently-updating state (like a counter or search query), Context causes too many re-renders. Use Zustand instead.
Zustand
Zustand is the sweet spot for most apps — minimal API, excellent performance, no boilerplate.
npm install zustandimport { create } from 'zustand';
// Define your store
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) => set(state => ({
items: [...state.items, { ...product, quantity: 1 }],
})),
removeItem: (id) => set(state => ({
items: state.items.filter(item => item.id !== id),
})),
updateQuantity: (id, quantity) => set(state => ({
items: state.items.map(item =>
item.id === id ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
// Computed values via getters
get totalItems() {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
},
get totalPrice() {
return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
}));
// Use anywhere — no Provider needed!
function CartIcon() {
const totalItems = useCartStore(state => state.totalItems);
return <span>Cart ({totalItems})</span>;
}
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem);
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
);
}
function Cart() {
const { items, removeItem, clearCart, totalPrice } = useCartStore();
return (
<div>
{items.map(item => (
<div key={item.id}>
<span>{item.name} × {item.quantity}</span>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<p>Total: ${totalPrice.toFixed(2)}</p>
<button onClick={clearCart}>Clear Cart</button>
</div>
);
}Zustand vs Context:
| Context API | Zustand | |
|---|---|---|
| Setup | More boilerplate | Minimal |
| Re-renders | All consumers re-render | Only subscribed slices |
| DevTools | No | Yes (Redux DevTools) |
| Best for | Theme, auth, locale | Cart, UI state, complex state |
Forms with React Hook Form + Zod
Manually managing form state with useState is tedious and error-prone. React Hook Form handles it efficiently with minimal re-renders.
Installation
npm install react-hook-form zod @hookform/resolversBasic Form
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. Define your schema
const schema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Infer the type from the schema
type FormData = z.infer<typeof schema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm({
resolver: zodResolver(schema),
});
const onSubmit = async (data) => {
try {
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
});
reset();
alert('Account created!');
} catch (err) {
console.error(err);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email')}
/>
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password')}
/>
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="error">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Sign Up'}
</button>
</form>
);
}Watching Field Values
const { register, watch, handleSubmit } = useForm();
const role = watch('role'); // Reactive — re-renders when 'role' changes
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register('role')}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
{role === 'admin' && (
<input
{...register('adminCode')}
placeholder="Admin access code"
/>
)}
</form>
);Dynamic Fields
import { useFieldArray, useForm } from 'react-hook-form';
function InvoiceForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: { items: [{ name: '', price: 0, qty: 1 }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} placeholder="Item name" />
<input {...register(`items.${index}.price`)} type="number" placeholder="Price" />
<input {...register(`items.${index}.qty`)} type="number" placeholder="Qty" />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', price: 0, qty: 1 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}Data Fetching with TanStack Query
useEffect + useState for data fetching works, but it doesn't handle caching, refetching, deduplication, or background updates. TanStack Query (formerly React Query) does all of this and more.
npm install @tanstack/react-querySetup
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute — don't refetch if data is fresh
retry: 2, // Retry failed requests twice
},
},
});
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
);Querying Data
import { useQuery } from '@tanstack/react-query';
async function fetchUser(userId) {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
function UserProfile({ userId }) {
const { data: user, isLoading, isError, error } = useQuery({
queryKey: ['user', userId], // Unique cache key
queryFn: () => fetchUser(userId),
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}What you get for free:
- Caches responses — same
queryKey= no duplicate requests - Shows stale data immediately while refetching in background
- Refetches when the window regains focus
- Retries on failure
- Shares data between components — no prop drilling
Mutations (POST, PUT, DELETE)
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) =>
fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
}).then(res => res.json()),
onSuccess: () => {
// Invalidate the posts list so it refetches with the new post
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSubmit = (e) => {
e.preventDefault();
const form = e.target;
mutation.mutate({
title: form.title.value,
body: form.body.value,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="body" placeholder="Content" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>Post created!</p>}
</form>
);
}Paginated Queries
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
function PostList() {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetch(`/api/posts?page=${page}&limit=10`).then(r => r.json()),
placeholderData: (prev) => prev, // Keep previous page data while loading next
});
return (
<div>
{isLoading ? (
<p>Loading...</p>
) : (
<ul>
{data.posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
<div className="pagination">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data?.hasNextPage || isPlaceholderData}
>
Next
</button>
</div>
</div>
);
}TanStack Query vs SWR
Both solve the same problem. TanStack Query is more feature-rich; SWR is lighter.
| TanStack Query | SWR | |
|---|---|---|
| Bundle size | ~13 KB | ~4 KB |
| Mutations | First-class useMutation | Manual |
| Pagination | Built-in | Manual |
| DevTools | Yes | No |
| Best for | Apps with complex data needs | Simple fetching |
For most projects, TanStack Query is the better default.
UI with Tailwind CSS and shadcn/ui
Tailwind CSS
Tailwind is a utility-first CSS framework — instead of writing CSS files, you apply pre-built classes directly in JSX.
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p// No custom CSS needed
function Button({ children, variant = 'primary' }) {
const base = 'px-4 py-2 rounded-md font-medium transition-colors';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
return (
<button className={`${base} ${variants[variant]}`}>
{children}
</button>
);
}
function Card({ title, description, imageUrl }) {
return (
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<img src={imageUrl} alt={title} className="w-full h-48 object-cover" />
<div className="p-4">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-1 text-sm text-gray-500">{description}</p>
</div>
</div>
);
}shadcn/ui
shadcn/ui gives you beautifully designed, accessible components built on Tailwind. Unlike a component library you install as a dependency, shadcn/ui copies the component source into your project — you own the code and can customize freely.
npx shadcn@latest init
npx shadcn@latest add button input dialog cardimport { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
function LoginDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Log In</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Log In</DialogTitle>
</DialogHeader>
<form className="space-y-4">
<Input type="email" placeholder="Email" />
<Input type="password" placeholder="Password" />
<Button type="submit" className="w-full">Log In</Button>
</form>
</DialogContent>
</Dialog>
);
}Handling Loading, Error, and Empty States
Every data-driven component needs three states beyond the "success" case. Make a habit of handling all of them:
function PostList() {
const { data: posts, isLoading, isError, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
});
// Loading
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
);
}
// Error
if (isError) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
<p className="text-red-800">Failed to load posts: {error.message}</p>
<button className="mt-2 text-red-600 underline">Retry</button>
</div>
);
}
// Empty
if (posts.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No posts yet.</p>
<a href="/new" className="mt-4 text-blue-600">Write the first one →</a>
</div>
);
}
// Success
return (
<ul className="space-y-4">
{posts.map(post => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}Putting It All Together: A Blog App
Here's a sketch of how Phase 2 tools combine in a real app:
File structure for a Phase 2 app:
src/
├── components/
│ ├── ui/ # shadcn/ui components
│ ├── Layout.jsx
│ └── ProtectedRoute.jsx
├── pages/
│ ├── Home.jsx
│ ├── Blog.jsx
│ ├── Post.jsx
│ └── Dashboard.jsx
├── hooks/
│ ├── useAuth.js
│ └── usePosts.js # Wraps TanStack Query
├── stores/
│ └── cartStore.js # Zustand store
├── lib/
│ └── queryClient.js # TanStack Query client
├── App.jsx
└── main.jsxPractice Exercises
Exercise 1 — Multi-page app Build a 3-page app (Home, Products, About) with React Router. Add a nav bar that highlights the active link.
Exercise 2 — Protected route
Add a /profile route that redirects to /login if the user isn't "logged in" (store a boolean in Zustand or Context). After "logging in", redirect back to /profile.
Exercise 3 — Search with query params
Build a searchable list. Store the search query in the URL (?q=...) with useSearchParams. The search should persist when the page is refreshed.
Exercise 4 — Contact form Build a contact form with React Hook Form and Zod. Fields: name (required), email (valid email), message (min 20 chars). Show field-level errors as the user types.
Exercise 5 — Data dashboard
Fetch posts from https://jsonplaceholder.typicode.com/posts with TanStack Query. Show a skeleton loading state. Add a search input that filters the displayed results. Show an empty state when no results match.
Summary
Phase 2 gives you the toolkit for real React applications:
| Tool | Problem It Solves |
|---|---|
| React Router v6 | Multi-page navigation, nested layouts, protected routes |
| Context API | Low-frequency global state (theme, auth, locale) |
| Zustand | Shared state without prop drilling and without re-render issues |
| React Hook Form + Zod | Forms with validation, without manual state management |
| TanStack Query | Server state — caching, background refetching, mutations |
| Tailwind CSS | Styling without leaving JSX |
| shadcn/ui | Accessible, customizable component library |
These are the tools you'll use on virtually every React project. Learn them once, apply them everywhere.
What's Next
In Post RN-4: Phase 3 — Next.js Full-Stack, you'll take this React knowledge into Next.js:
- File-based routing with the App Router
- Server-Side Rendering and Static Generation
- React Server Components
- API Route Handlers and Server Actions
- Built-in image, font, and script optimization
Series Index
| Post | Title | Status |
|---|---|---|
| RN-1 | React.js & Next.js Roadmap | ✅ Complete |
| RN-2 | Phase 1: React Fundamentals | ✅ Complete |
| RN-3 | Phase 2: React Ecosystem | ✅ You are here |
| RN-4 | Phase 3: Next.js Full-Stack | Coming Soon |
| RN-5 | Deep Dive: React Hooks Mastery | Coming Soon |
| RN-6 | Deep Dive: State Management | Coming Soon |
| RN-7 | Deep Dive: App Router & Server Components | Coming Soon |
| RN-8 | Deep Dive: Data Fetching Patterns | Coming Soon |
| RN-9 | Deep Dive: Performance Optimization | Coming Soon |
| RN-10 | Deep Dive: Authentication in Next.js | Coming Soon |
| RN-11 | Deep Dive: Testing React & Next.js | Coming Soon |
| RN-12 | Deep Dive: Deploying Next.js Apps | Coming Soon |
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.