This mid-course project integrates everything from Modules 1–4 into one real application: a full-featured blog with routing across multiple pages, server data fetched and cached with a data library, forms for creating and editing posts, and accessibility throughout. It is the moment the individual skills — components, hooks, React 19 features, routing, data fetching, forms, and a11y — come together into a coherent product.
You will build a blog where users can browse a post list, read individual posts, and create and edit posts through validated forms, with data managed by TanStack Query and navigation handled by React Router. The emphasis is integration and good structure: a clean component tree, server state in the query cache, route-based pages, accessible forms, and responsive feedback during loading and mutations.
Learning Objectives
- Integrate components, hooks, React 19 features, routing, data fetching, forms, and accessibility into one application.
- Build multi-page navigation with React Router (list, detail, create, edit) including nested layout and dynamic routes.
- Manage server state with TanStack Query: cached reads, mutations, and cache invalidation.
- Implement create/edit forms with validation (React Hook Form or React 19 Actions) and clear feedback.
- Apply accessibility throughout: semantic markup, labelled forms, focus management, and keyboard support.
- Handle loading, error, and empty states gracefully across the app.
Technical Requirements
- Routes for a post list (/), a post detail (/posts/:id), a create page (/posts/new), and an edit page (/posts/:id/edit), within a shared layout.
- Post list and detail data fetched and cached via TanStack Query (useQuery), with loading and error states.
- Create and edit implemented with useMutation, invalidating the relevant queries so the UI stays consistent.
- Forms built with React Hook Form (or React 19 Actions) including validation and accessible error messaging.
- Accessible navigation (semantic nav, NavLink), labelled inputs, and focus management on route changes or modals.
- Graceful empty/loading/error UI, and 404 handling for unknown routes.
Architecture & Design
The app uses React Router for navigation with a root layout (header/nav plus an Outlet) wrapping four pages: PostList, PostDetail, PostForm (for both create and edit), and a NotFound catch-all. TanStack Query manages all server state — a posts list query, a per-post detail query keyed by id, and mutations for create/update that invalidate those queries. Forms use React Hook Form with validation, and components are organised into pages, reusable UI (from Module 1's library), and hooks.
Data flows cleanly: route components read server data from the query cache via useQuery (never copied into local state), forms submit through useMutation which invalidates affected queries so reads refresh automatically, and client state (form values, UI toggles) stays local. Accessibility is woven in — semantic structure, labelled and error-associated inputs, focus moved to headings or forms on navigation — rather than bolted on.
// Structure: routing + query cache + forms, each concern in its place
// main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{ path: "/", element: <Layout />, children: [
{ index: true, element: <PostList /> },
{ path: "posts/new", element: <PostForm mode="create" /> },
{ path: "posts/:id", element: <PostDetail /> },
{ path: "posts/:id/edit", element: <PostForm mode="edit" /> },
{ path: "*", element: <NotFound /> },
]},
]);
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);Phase 1 — Routing Skeleton and Layout
Set up React Router with a root Layout (semantic header and nav using NavLink, plus an Outlet) and the four routes: list, detail, create, edit, and a NotFound catch-all. Stub each page component so navigation works end to end before wiring data. This establishes the app's navigable skeleton and shared chrome.
Use Link/NavLink for navigation and useParams in the detail and edit pages to read the post id. Confirm the back button works, URLs are shareable, and the layout stays mounted across navigations. Add focus management so that navigating to a new page moves focus to its main heading, aiding keyboard and screen-reader users.
function Layout() {
return (
<>
<header>
<nav aria-label="Main">
<NavLink to="/">Posts</NavLink>
<NavLink to="/posts/new">Write</NavLink>
</nav>
</header>
<main><Outlet /></main>
</>
);
}
function PostDetail() { const { id } = useParams(); /* fetch in Phase 2 */ return <h1>Post {id}</h1>; }
function NotFound() { return <h1>Page not found</h1>; }Phase 2 — Data Fetching with TanStack Query
Wire the post list and detail to the server using useQuery: a ['posts'] query for the list and a ['post', id] query for the detail. Render loading, error, and empty states properly. The query cache becomes the single source of truth for server data — never copy it into useState — so multiple components stay consistent automatically.
Extract the queries into small custom hooks (usePosts, usePost(id)) to keep page components clean and reuse the fetching logic. Tune staleTime sensibly so navigating back to the list does not always refetch, and rely on background revalidation to keep data fresh. This phase makes the blog display real, cached data.
// hooks/usePosts.js — encapsulate server-state access
import { useQuery } from "@tanstack/react-query";
export const usePosts = () =>
useQuery({ queryKey: ["posts"], queryFn: () => api.getPosts(), staleTime: 30_000 });
export const usePost = (id) =>
useQuery({ queryKey: ["post", id], queryFn: () => api.getPost(id), enabled: !!id });
function PostList() {
const { data: posts, isLoading, isError } = usePosts();
if (isLoading) return <p>Loading posts…</p>;
if (isError) return <p role="alert">Couldn't load posts.</p>;
if (posts.length === 0) return <p>No posts yet. <Link to="/posts/new">Write one</Link></p>;
return <ul>{posts.map(p => <li key={p.id}><Link to={`/posts/${p.id}`}>{p.title}</Link></li>)}</ul>;
}Phase 3 — Forms, Mutations, and Accessibility
Build PostForm (handling both create and edit) with React Hook Form: registered, validated fields with accessible labels and error messages, and submission through a useMutation. On success, invalidate the ['posts'] list and the ['post', id] detail queries so the UI reflects the change, and navigate to the new or updated post with useNavigate.
For edit mode, pre-fill the form from the post detail query. Ensure full accessibility: labels tied to inputs, errors associated via aria-describedby and announced, focus moved to the first invalid field on a failed submit, and a disabled submit button with pending feedback while the mutation runs. Handle the mutation's error state with a clear, accessible message.
function PostForm({ mode }) {
const { id } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: existing } = usePost(mode === "edit" ? id : null);
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm({ values: existing }); // pre-fill on edit
const save = useMutation({
mutationFn: (data) => mode === "edit" ? api.updatePost(id, data) : api.createPost(data),
onSuccess: (saved) => {
qc.invalidateQueries({ queryKey: ["posts"] });
qc.invalidateQueries({ queryKey: ["post", saved.id] });
navigate(`/posts/${saved.id}`);
},
});
return (
<form onSubmit={handleSubmit((d) => save.mutate(d))}>
<label htmlFor="title">Title</label>
<input id="title" aria-invalid={!!errors.title}
{...register("title", { required: "Title is required" })} />
{errors.title && <span role="alert">{errors.title.message}</span>}
<button disabled={isSubmitting || save.isPending}>
{save.isPending ? "Saving…" : "Save"}
</button>
{save.isError && <p role="alert">Failed to save. Try again.</p>}
</form>
);
}Evaluation Rubric
- Routing (20%): list/detail/create/edit routes within a shared layout, dynamic params, NavLink, and 404 handling all working with client-side navigation.
- Data fetching (20%): server state managed via TanStack Query with proper loading/error/empty states; data read from the cache, not copied into local state.
- Mutations & consistency (15%): create/edit via useMutation with correct query invalidation so the UI stays consistent after changes.
- Forms (15%): validated create/edit forms (RHF or Actions) with pre-fill on edit and clear submission/pending/error feedback.
- Accessibility (20%): semantic markup, labelled and error-associated inputs, focus management, and keyboard operability throughout.
- Code quality & UX (10%): clean component/hook structure, reusable UI, and graceful handling of all states.
Extension Challenges: Add comments to each post using a React 19 Action with useOptimistic for instant feedback (combining Module 3); add search and filtering of the post list with a transition (useTransition) so typing stays responsive over a large list; persist a draft of the post form to localStorage via a useLocalStorage custom hook so unsaved work survives a reload; add Suspense boundaries with route-level data loading; and write a few tests (previewing Module 5) for the form validation and a query hook.