Back to blog

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

reactreact-routerzustandtanstack-queryfrontend
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-dom

Basic 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>
  );
}
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 zustand
import { 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 APIZustand
SetupMore boilerplateMinimal
Re-rendersAll consumers re-renderOnly subscribed slices
DevToolsNoYes (Redux DevTools)
Best forTheme, auth, localeCart, 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/resolvers

Basic 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-query

Setup

// 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 QuerySWR
Bundle size~13 KB~4 KB
MutationsFirst-class useMutationManual
PaginationBuilt-inManual
DevToolsYesNo
Best forApps with complex data needsSimple 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 card
import { 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.jsx

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

ToolProblem It Solves
React Router v6Multi-page navigation, nested layouts, protected routes
Context APILow-frequency global state (theme, auth, locale)
ZustandShared state without prop drilling and without re-render issues
React Hook Form + ZodForms with validation, without manual state management
TanStack QueryServer state — caching, background refetching, mutations
Tailwind CSSStyling without leaving JSX
shadcn/uiAccessible, 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

PostTitleStatus
RN-1React.js & Next.js Roadmap✅ Complete
RN-2Phase 1: React Fundamentals✅ Complete
RN-3Phase 2: React Ecosystem✅ You are here
RN-4Phase 3: Next.js Full-StackComing Soon
RN-5Deep Dive: React Hooks MasteryComing Soon
RN-6Deep Dive: State ManagementComing Soon
RN-7Deep Dive: App Router & Server ComponentsComing Soon
RN-8Deep Dive: Data Fetching PatternsComing Soon
RN-9Deep Dive: Performance OptimizationComing Soon
RN-10Deep Dive: Authentication in Next.jsComing Soon
RN-11Deep Dive: Testing React & Next.jsComing Soon
RN-12Deep Dive: Deploying Next.js AppsComing 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.