Mastering React Router v7: A Comprehensive Step-by-Step Tutorial
Welcome to this comprehensive guide on leveraging React Router v7 as a full-fledged framework for building modern, data-driven React applications. Whether you're a beginner aiming to understand the fundamentals or an experienced developer looking to deepen your knowledge, this tutorial covers everything you need to know. We'll walk through each topic step-by-step, complete with insightful explanations and practical code examples.
Prefer watching? Check out this in video form:
Table of Contents
- Installation
- Routing
- Route Module
- Rendering Strategies
- Data Loading
- Actions
- Navigating
- Pending UI
- Testing
- Custom Framework
1. Installation
Setting up your React Router v7 project correctly from the start can save you time and headaches later. Most projects begin with a template that provides a solid foundation, including project structure, essential configurations, and helpful scripts.
Steps to Install React Router v7
- Create a New Project with the React Router Template:
npx create-react-router@latest my-react-router-app
- Navigate to the Project Directory and Install Dependencies:
cd my-react-router-app
npm install
npm run dev
- View the Application:
Open your browser and navigate to http://localhost:5173 to see your running app.
Additional Setup
GitHub Repository:
You can view the template's GitHub repository to understand its structure and manually set up your project if needed.Deployment Templates:
Explore various deployment-ready templates provided by React Router to deploy your app seamlessly to your preferred hosting service.
2. Routing
Routing is the backbone of any single-page application (SPA). React Router v7 allows you to define routes that map URLs to components, enabling dynamic content rendering based on the user's navigation.
Configuring Routes
Routes are defined in the app/routes.ts
file. Each route requires two main parts:
- URL Pattern: The path segment that matches the URL.
- Route Module: The file that contains the route's logic and UI.
Basic Route Configuration
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
route("some/path", "./some/file.tsx"),
// pattern ^ ^ module file
] satisfies RouteConfig;
Advanced Route Configuration
You can create more complex routing structures using nested routes, layouts, and prefixes.
import {
type RouteConfig,
route,
index,
layout,
prefix,
} from "@react-router/dev/routes";
export default [
index("./home.tsx"),
route("about", "./about.tsx"),
layout("./auth/layout.tsx", [
route("login", "./auth/login.tsx"),
route("register", "./auth/register.tsx"),
]),
...prefix("concerts", [
index("./concerts/home.tsx"),
route(":city", "./concerts/city.tsx"),
route("trending", "./concerts/trending.tsx"),
]),
] satisfies RouteConfig;
-
index
: Defines a default child route. -
layout
: Creates a nested layout without adding to the URL. -
prefix
: Adds a common path prefix to a group of routes.
File System Routing (Optional)
If you prefer defining routes based on your file system's structure, use the @react-router/fs-routes
package. This allows for automatic route generation based on your directory and file naming conventions.
3. Route Module
A Route Module is a file that defines the behavior and UI for a specific route. It can contain:
- Loader Functions: For fetching data before rendering.
- Action Functions: For handling data mutations like form submissions.
- Default Exported Components: The UI to render when the route matches.
Example Route Module
// app/routes/team.tsx
import type { Route } from "./+types/team";
export async function loader({ params }: Route.LoaderArgs) {
let team = await fetchTeam(params.teamId);
return { name: team.name };
}
export default function Team({ loaderData }: Route.ComponentProps) {
return <h1>{loaderData.name}</h1>;
}
Nested Routes
Routes can be nested to create complex layouts. The parent route renders an <Outlet />
where child routes will appear.
// app/routes/dashboard.tsx
import { Outlet } from "react-router";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Child routes render here */}
<Outlet />
</div>
);
}
// app/routes.ts
export default [
route("dashboard", "./dashboard.tsx", [
index("./home.tsx"),
route("settings", "./settings.tsx"),
]),
] satisfies RouteConfig;
Root Route
Every route defined in routes.ts
is nested inside the special app/root.tsx
module. This root route can provide global layouts, context providers, or error boundaries.
4. Rendering Strategies
React Router v7 supports various rendering strategies to cater to different application needs and deployment environments.
1. Client Side Rendering (CSR)
CSR renders all routes in the browser. This is ideal for Single Page Applications (SPAs) where the server only serves static assets.
// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;
Benefits:
- Simpler setup without needing a server for rendering.
- Faster client-side transitions once the app is loaded.
2. Server Side Rendering (SSR)
SSR renders routes on the server, sending fully populated HTML to the client. This enhances SEO and reduces initial load times.
// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;
Benefits:
- Improved SEO as search engines can index fully rendered HTML.
- Faster initial load times since the client receives fully rendered pages.
- Better performance on slow devices as the server does the heavy lifting.
Note: SSR requires a server environment that supports rendering React components on the server.
3. Static Pre-rendering
Static Pre-rendering generates static HTML files for specified routes at build time. This is excellent for deploying to static hosting services and improving performance and SEO.
// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
async prerender() {
return ["/", "/about", "/contact"];
},
} satisfies Config;
Benefits:
- Combines the performance benefits of static sites with the flexibility of React.
- Ideal for pages that don't change frequently.
- Simplifies deployment as it can be hosted on any static file server or CDN.
Combining Strategies
You can mix rendering strategies within the same application. For example, statically pre-render some routes while server-rendering others.
5. Data Loading
Efficient data management is crucial for building responsive and dynamic applications. React Router v7 provides powerful data loading capabilities through loaders and clientLoaders.
Loader Functions
Loader Functions fetch data before a route renders. They run on the server during SSR or on the client during navigation.
// app/routes/product.tsx
import type { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
const res = await fetch(`/api/products/${params.pid}`);
if (!res.ok) throw new Response("Not Found", { status: 404 });
return res.json();
}
export default function Product({ loaderData }: Route.ComponentProps) {
const { name, description } = loaderData;
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}
Client Loader
clientLoader fetches data exclusively on the client side. This is useful for client-only data fetching scenarios.
// app/routes/clientProduct.tsx
import type { Route } from "./+types/product";
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const res = await fetch(`/api/products/${params.pid}`);
return res.json();
}
export default function ClientProduct({ loaderData }: Route.ComponentProps) {
const { name, description } = loaderData;
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}
Combining Loaders
You can use both loader
and clientLoader
in the same route. The loader
handles SSR or pre-rendering, while clientLoader
manages client-side data fetching for subsequent navigations.
6. Actions
Actions handle data mutations like creating, updating, or deleting resources. They work in tandem with forms to process user input and update the application state.
Types of Actions
-
Server Actions (
action
): Run on the server and are not included in client bundles. -
Client Actions (
clientAction
): Run exclusively in the browser, ideal for client-side only data mutations.
Client Action Example
// app/routes/updateProject.tsx
import { Form, redirect } from "react-router";
import type { Route } from "./+types/project";
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const title = formData.get("title");
const res = await fetch(`/api/projects/${formData.get("projectId")}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) {
return { success: false, message: "Failed to update project." };
}
return { success: true };
}
export default function UpdateProject({ loaderData }: Route.ComponentProps) {
return (
<Form method="post">
<input type="hidden" name="projectId" value={loaderData.id} />
<label>
Project Title:
<input type="text" name="title" defaultValue={loaderData.title} />
</label>
<button type="submit">Update</button>
</Form>
);
}
Server Action Example
// app/routes/deleteProject.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/project";
export async function action({ params }: Route.ActionArgs) {
const res = await fetch(`/api/projects/${params.projectId}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Response("Failed to delete project.", { status: 500 });
}
return redirect("/projects");
}
export default function DeleteProject() {
// This component can remain empty as the action handles the redirection
return null;
}
Calling Actions
-
Using
<Form>
:
<Form method="post" action="/projects/123/delete">
<button type="submit">Delete Project</button>
</Form>
-
Using
useSubmit()
:
import { useSubmit } from "react-router";
function DeleteButton({ projectId }) {
const submit = useSubmit();
const handleDelete = () => {
submit(null, { method: "post", action: `/projects/${projectId}/delete` });
};
return <button onClick={handleDelete}>Delete Project</button>;
}
-
Using
<fetcher.Form>
:
import { useFetcher } from "react-router";
function DeleteProject({ projectId }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action={`/projects/${projectId}/delete`}>
<button type="submit">Delete Project</button>
</fetcher.Form>
);
}
7. Navigating
Effective navigation is essential for a seamless user experience. React Router v7 offers several components and hooks to manage navigation within your application.
1. <Link>
Use <Link>
for simple, unstyled navigation between routes.
import { Link } from "react-router";
export function Home() {
return (
<div>
<h1>Home</h1>
<Link to="/about">Go to About Page</Link>
</div>
);
}
2. <NavLink>
<NavLink>
is similar to <Link>
but provides styling capabilities based on the active route.
import { NavLink } from "react-router";
export function Navbar() {
return (
<nav>
<NavLink
to="/"
end
className={({ isActive }) => (isActive ? "active" : "")}
>
Home
</NavLink>
<NavLink
to="/about"
className={({ isActive }) => (isActive ? "active" : "")}
>
About
</NavLink>
</nav>
);
}
Styling Active Links:
/* styles.css */
.active {
font-weight: bold;
color: blue;
}
3. <Form>
Forms can navigate and mutate data based on user input.
import { Form } from "react-router";
export function SearchForm() {
return (
<Form method="get" action="/search">
<input type="text" name="q" placeholder="Search..." />
<button type="submit">Search</button>
</Form>
);
}
4. redirect
Use redirect
within loaders or actions to programmatically navigate the user.
import { redirect } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
return { userName: user.name };
}
5. useNavigate()
useNavigate
is a hook that allows for imperative navigation within your components.
import { useNavigate } from "react-router";
export function LogoutButton() {
const navigate = useNavigate();
const handleLogout = () => {
performLogout();
navigate("/login");
};
return <button onClick={handleLogout}>Logout</button>;
}
8. Pending UI
Providing feedback during data fetching or mutations enhances user experience. React Router v7 offers tools to manage and display pending states.
1. Global Pending Navigation
Use useNavigation
to determine if a navigation is in progress and display a global spinner or loader.
import { useNavigation, Outlet } from "react-router";
export default function Root() {
const navigation = useNavigation();
const isNavigating = Boolean(navigation.state !== "idle");
return (
<div>
{isNavigating && <div className="spinner">Loading...</div>}
<Outlet />
</div>
);
}
2. Local Pending Indicators
Display loading indicators next to specific links or buttons when they are in a pending state.
import { NavLink } from "react-router";
export function Navbar() {
return (
<nav>
<NavLink to="/home">
{({ isPending }) => (
<>Home {isPending && <span className="spinner">⏳</span>}</>
)}
</NavLink>
<NavLink to="/about">
{({ isPending }) => (
<>About {isPending && <span className="spinner">⏳</span>}</>
)}
</NavLink>
</nav>
);
}
3. Pending Form Submission
Use <fetcher.Form>
to manage the pending state of individual form submissions without affecting the entire page.
import { useFetcher } from "react-router";
export function CreatePostForm() {
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
return (
<fetcher.Form method="post" action="/posts/create">
<input type="text" name="title" placeholder="Post Title" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Post"}
</button>
</fetcher.Form>
);
}
4. Optimistic UI
Implement optimistic updates to reflect changes in the UI immediately, even before the server confirms the action.
import { useFetcher } from "react-router";
export function ToggleFavorite({ post }) {
const fetcher = useFetcher();
const isFavorited = fetcher.formData
? fetcher.formData.get("favorite") === "true"
: post.favorite;
return (
<fetcher.Form method="post" action={`/posts/${post.id}/toggle-favorite`}>
<button
type="submit"
name="favorite"
value={isFavorited ? "false" : "true"}
>
{isFavorited ? "★" : "☆"}
</button>
</fetcher.Form>
);
}
9. Testing
Ensuring your application behaves as expected is crucial. React Router v7 provides tools to facilitate testing, especially for components that rely on routing context.
Using createRoutesStub
createRoutesStub
creates a mock routing environment, allowing you to test components in isolation without needing the entire app.
Example: Testing a Login Form
import { createRoutesStub } from "react-router";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
test("LoginForm renders error messages", async () => {
const USER_MESSAGE = "Username is required";
const PASSWORD_MESSAGE = "Password is required";
const Stub = createRoutesStub([
{
path: "/login",
Component: LoginForm,
action() {
return {
errors: {
username: USER_MESSAGE,
password: PASSWORD_MESSAGE,
},
};
},
},
]);
// Render the stub at "/login"
render(<Stub initialEntries={["/login"]} />);
// Simulate form submission
userEvent.click(screen.getByText("Login"));
// Assert error messages are displayed
await waitFor(() => screen.findByText(USER_MESSAGE));
await waitFor(() => screen.findByText(PASSWORD_MESSAGE));
});
Testing Navigation and Data Loading
Ensure your components correctly handle data loading and navigation by mocking route loaders and actions.
import { createRoutesStub } from "react-router";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PostList } from "./PostList";
test("PostList deletes a post", async () => {
const Stub = createRoutesStub([
{
path: "/",
Component: PostList,
loader() {
return {
posts: [
{ id: 1, title: "First Post" },
{ id: 2, title: "Second Post" },
],
};
},
action({ params }) {
if (params.postId === "1") {
return { isDeleted: true };
}
return { isDeleted: false };
},
},
]);
render(<Stub initialEntries={["/"]} />);
const deleteButtons = screen.getAllByText("Delete");
userEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(screen.queryByText("First Post")).not.toBeInTheDocument();
});
expect(screen.getByText("Second Post")).toBeInTheDocument();
});
Best Practices for Testing
- Mock Loaders and Actions: Ensure that your tests simulate the necessary data fetching and mutations.
- Isolate Components: Test components in isolation by providing the required context using stubs.
-
Simulate User Interactions: Use tools like
@testing-library/user-event
to mimic real user interactions. - Assert UI Changes: Verify that the UI updates correctly based on different states (e.g., loading, success, error).
10. Custom Framework
React Router v7's extensive features enable it to function as a complete framework for React applications. Here's how it integrates various aspects of development to provide a cohesive and scalable solution.
Comprehensive Features
File-Based or Configuration-Based Routing:
Choose between defining routes via configuration files or using file-system-based conventions for automatic route generation.Nested Routes and Layouts:
Create reusable layouts and component hierarchies by nesting routes, promoting DRY (Don't Repeat Yourself) principles.Data Loading and Actions:
Handle data fetching and mutations directly within route modules, streamlining data management and reducing boilerplate.Rendering Strategies:
Flexibly choose between client-side rendering, server-side rendering, and static pre-rendering based on your application's needs.State Management:
Efficiently manage navigation and form states with built-in hooks likeuseNavigation
,useFetcher
, anduseSubmit
.Pending UI and Optimistic Updates:
Enhance user experience by displaying loading states and implementing optimistic UI updates without complex state management.Testing Utilities:
Easily test components within a mocked routing context, ensuring reliability and maintainability.SEO and Performance Optimization:
Improve SEO with SSR and static pre-rendering, and optimize performance with efficient data fetching and caching strategies.
Building Scalable Applications
By combining these features, React Router v7 allows you to build scalable, maintainable, and high-performance applications with minimal boilerplate. Its design encourages clear data flow, separation of concerns, and component reusability, which are essential for large-scale applications.
Example: Building a Simple Blog
Let's see how these features come together in a simple blog application.
Project Structure
my-react-router-app/
├── app/
│ ├── routes.ts
│ ├── root.tsx
│ ├── data.ts
│ └── layouts/
│ └── navbar.tsx
├── public/
├── package.json
├── tailwind.config.js
└── ...other files
Route Configuration
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { index, layout, route } from "@react-router/dev/routes";
export default [
layout("layouts/navbar.tsx", {
clientLoader: "layouts/navbar.tsx:clientLoader",
clientAction: "layouts/navbar.tsx:clientAction",
children: [
index("routes/home.tsx"),
route("posts/:postId/destroy", "routes/destroy-post.tsx", {
clientAction: "routes/destroy-post.tsx:clientAction",
}),
],
}),
] satisfies RouteConfig;
Navbar Layout
// app/layouts/navbar.tsx
import {
Form,
Link,
Outlet,
useNavigation,
useSubmit,
useEffect,
} from "react-router";
import { getPosts, createEmptyPost } from "../data";
import type { Route } from "./+types/navbar";
export async function clientLoader() {
const posts = await getPosts();
return { posts };
}
export async function clientAction() {
const post = await createEmptyPost();
return { isCreated: true };
}
export default function NavbarLayout({ loaderData }: Route.ComponentProps) {
const { isCreated } = loaderData;
const navigation = useNavigation();
const submit = useSubmit();
return (
<>
<header className="flex items-center justify-between p-4 bg-gray-800 text-white">
<Link to="/" className="text-xl font-bold hover:text-gray-300">
Posts Manager
</Link>
<nav className="flex items-center gap-4">
<Form method="post">
<button
type="submit"
className="bg-blue-600 hover:bg-blue-500 transition px-3 py-1 rounded text-white"
>
New
</button>
</Form>
</nav>
</header>
<main className="p-4">
<Outlet context={loaderData} />
</main>
</>
);
}
Home Route
// app/routes/home.tsx
import { Form, useOutletContext } from "react-router";
type LoaderData = {
posts: Array<{ id: number; title: string }>;
};
export default function Home() {
const { posts } = useOutletContext<LoaderData>();
if (!posts.length) {
return (
<p className="text-gray-700">
<i>No posts</i>
</p>
);
}
return (
<ul className="space-y-2">
{posts.map((post) => (
<li
key={post.id}
className="flex items-center justify-between border border-gray-200 p-2 rounded"
>
<span className="font-medium">{post.title || <i>No Title</i>}</span>
<Form method="post" action={`posts/${post.id}/destroy`}>
<button
type="submit"
className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-500 transition"
onClick={(e) => {
const response = confirm(
"Are you sure you want to delete this post?"
);
if (!response) e.preventDefault();
}}
>
Delete
</button>
</Form>
</li>
))}
</ul>
);
}
Destroy Post Action
// app/routes/destroy-post.tsx
import { redirect } from "react-router";
import { deletePost } from "../data";
import type { Route } from "./+types/destroy-post";
export async function clientAction({ params }: Route.ClientActionArgs) {
await deletePost(params.postId);
return redirect("/");
}
export default function DestroyPost() {
// This component can remain empty as the action handles the redirection
return null;
}
Data Management
// app/data.ts
type Post = {
userId: number;
id: number;
title: string;
body: string;
};
let postsCache: Post[] = [];
export async function getPosts() {
await new Promise((res) => setTimeout(res, 300)); // Simulate delay
if (postsCache.length === 0) {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
postsCache = await res.json();
}
return postsCache;
}
export async function createEmptyPost() {
const newPost = {
userId: 1,
title: "New Post",
body: "This is a newly created post",
};
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
const created = await res.json();
const fullPost = { ...newPost, id: created.id };
postsCache.unshift(fullPost); // Add new post at the top
return fullPost;
}
export async function deletePost(id: string | undefined) {
if (!id) return null;
await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
method: "DELETE",
});
postsCache = postsCache.filter((p) => p.id !== Number(id));
return true;
}
Styling with Tailwind CSS
Enhance the visual appeal of your application using Tailwind CSS.
- Install Tailwind CSS:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
-
Configure
tailwind.config.js
:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
- Add Tailwind Directives to CSS:
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
- Apply Tailwind Classes:
Use Tailwind's utility classes to style your components, as seen in the Navbar and Home route examples above.
11. Conclusion
By following this step-by-step tutorial, you've learned how to harness the full potential of React Router v7 as a comprehensive framework for your React applications. From setting up your project and configuring routes to managing data, handling actions, and optimizing user experience with pending UI states, React Router provides all the tools you need to build scalable and maintainable applications.
Key Takeaways:
- Comprehensive Routing: Define and manage routes efficiently using configuration or file-system conventions.
- Data Management: Utilize loaders and actions to handle data fetching and mutations seamlessly within route modules.
- Flexible Rendering: Choose between client-side rendering, server-side rendering, and static pre-rendering based on your application's needs.
- Enhanced User Experience: Implement pending UI states and optimistic updates to provide responsive and intuitive interactions.
- Testing: Leverage testing utilities to ensure your components and routes behave as expected.
- Scalability: Build scalable applications by combining React Router's features, promoting clean architecture and maintainability.
For more tutorials, examples, and in-depth guides, be sure to subscribe and check out my YouTube channel Pedrotechnologies.
Top comments (2)
For those that hoping to stay within the stable parts of React (CSR/SPA) and don't want to deal with a routing library that churns through changes regularly, there is also the simple alternative library wouter.
Great Crash Course.