Routing in React with React Router and TypeScript – Part 3: Data Loading and CRUD Flows

Learn how to use React Router’s data APIs (loaders and actions) with TypeScript in a Vite React app. We’ll build a simple CRUD flow inside the dashboard section using route-based data fetching and mutations.

FSI
Full Stack Insights
8 min read...

In Part 2, we added a /dashboard section with nested routes and a dedicated layout.

In Part 3, we’ll make the dashboard feel more like a real app by introducing data loading and mutations using React Router’s data APIs:

  • Loaders – fetch data before a route renders
  • Actions – handle mutations like create/update/delete
  • useLoaderData / useActionData – consume data in components
  • Handling loading, errors, and optimistic updates at the route level

We’ll build a small "Tasks" feature under /dashboard/tasks to demonstrate a CRUD-style flow.

For simplicity, we'll keep data in memory on the client. In a real application, the same patterns apply when integrating with an API.


1. Enable data routers with createBrowserRouter

If you followed Parts 1 and 2, you're already using createBrowserRouter and RouterProvider. That means you can add loaders and actions directly to your route definitions.

We'll focus on the dashboard branch and add a new tasks route group with data APIs.


2. Create a simple in-memory tasks "service"

Start by modeling a Task type and a simple in-memory store.

Create src/features/tasks/taskTypes.ts:

export type TaskStatus = "todo" | "in-progress" | "done";

export interface Task {
  id: string;
  title: string;
  description?: string;
  status: TaskStatus;
}

Create src/features/tasks/taskStore.ts:

import type { Task } from "./taskTypes";

let tasks: Task[] = [
  {
    id: "t1",
    title: "Plan Q1 roadmap",
    description: "Gather feature ideas and align with stakeholders.",
    status: "in-progress",
  },
  {
    id: "t2",
    title: "Refactor auth module",
    status: "todo",
  },
  {
    id: "t3",
    title: "Ship mobile app v1",
    status: "done",
  },
];

export function getTasks(): Task[] {
  return tasks;
}

export function getTaskById(id: string): Task | undefined {
  return tasks.find((task) => task.id === id);
}

export function createTask(input: Omit<Task, "id">): Task {
  const newTask: Task = {
    id: crypto.randomUUID(),
    ...input,
  };
  tasks = [newTask, ...tasks];
  return newTask;
}

export function updateTask(id: string, updates: Partial<Omit<Task, "id">>): Task | undefined {
  let updated: Task | undefined;

  tasks = tasks.map((task) => {
    if (task.id !== id) return task;
    updated = { ...task, ...updates };
    return updated;
  });

  return updated;
}

export function deleteTask(id: string): void {
  tasks = tasks.filter((task) => task.id !== id);
}

This mimics what a backend might do, but stores everything in memory for demo purposes.


3. Add route loaders and actions for tasks

We'll add a nested tasks route under /dashboard with:

  • /dashboard/tasks → list + create form
  • /dashboard/tasks/:taskId → details + inline edit

Create src/routes/dashboard/tasksRoutes.tsx with the route configuration helpers:

import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router-dom";
import {
  json,
  redirect,
  useActionData,
  useLoaderData,
  useNavigation,
  Link,
} from "react-router-dom";
import {
  createTask,
  deleteTask,
  getTaskById,
  getTasks,
  updateTask,
} from "../../features/tasks/taskStore";
import type { Task } from "../../features/tasks/taskTypes";

// Loader for /dashboard/tasks
export async function tasksListLoader() {
  const items = getTasks();
  return json({ tasks: items });
}

// Action for /dashboard/tasks (create new task)
export async function tasksListAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();

  const title = String(formData.get("title") || "").trim();
  const description = String(formData.get("description") || "").trim();

  if (!title) {
    return json({ error: "Title is required" }, { status: 400 });
  }

  const newTask = createTask({
    title,
    description: description || undefined,
    status: "todo",
  });

  return redirect(`/dashboard/tasks/${newTask.id}`);
}

// Loader for /dashboard/tasks/:taskId
export async function taskDetailsLoader({ params }: LoaderFunctionArgs) {
  const taskId = params.taskId;

  if (!taskId) {
    throw json({ message: "Task ID is required" }, { status: 400 });
  }

  const task = getTaskById(taskId);

  if (!task) {
    throw json({ message: "Task not found" }, { status: 404 });
  }

  return json({ task });
}

// Action for /dashboard/tasks/:taskId (update or delete)
export async function taskDetailsAction({ request, params }: ActionFunctionArgs) {
  const taskId = params.taskId;

  if (!taskId) {
    throw json({ message: "Task ID is required" }, { status: 400 });
  }

  const formData = await request.formData();
  const intent = formData.get("intent");

  if (intent === "delete") {
    deleteTask(taskId);
    return redirect("/dashboard/tasks");
  }

  const title = String(formData.get("title") || "").trim();
  const description = String(formData.get("description") || "").trim();
  const status = String(formData.get("status") || "todo");

  if (!title) {
    return json({ error: "Title is required" }, { status: 400 });
  }

  const updated = updateTask(taskId, {
    title,
    description: description || undefined,
    status: status as Task["status"],
  });

  if (!updated) {
    throw json({ message: "Task not found" }, { status: 404 });
  }

  return json({ task: updated });
}

// UI components that consume route data

export function TasksListRoute() {
  const { tasks } = useLoaderData() as { tasks: Task[] };
  const actionData = useActionData() as { error?: string } | undefined;
  const navigation = useNavigation();

  const isSubmitting = navigation.state === "submitting";

  return (
    <section>
      <h1>Tasks</h1>
      <p>
        A tiny CRUD-style example using React Router loaders and actions.
      </p>

      <form method="post" style={{ marginBottom: "1.5rem" }}>
        <h2>Create a new task</h2>
        <div>
          <label>
            Title
            <input name="title" type="text" required />
          </label>
        </div>
        <div>
          <label>
            Description
            <textarea name="description" rows={3} />
          </label>
        </div>
        {actionData?.error && (
          <p style={{ color: "red" }}>{actionData.error}</p>
        )}
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? "Creating..." : "Create task"}
        </button>
      </form>

      <h2>Existing tasks</h2>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link to={task.id}>
              {task.title}{task.status}
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

export function TaskDetailsRoute() {
  const { task } = useLoaderData() as { task: Task };
  const actionData = useActionData() as { error?: string } | undefined;
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <section>
      <h1>Edit Task</h1>
      <form method="post" style={{ marginBottom: "1rem" }}>
        <div>
          <label>
            Title
            <input name="title" type="text" defaultValue={task.title} />
          </label>
        </div>
        <div>
          <label>
            Description
            <textarea
              name="description"
              rows={3}
              defaultValue={task.description}
            />
          </label>
        </div>
        <div>
          <label>
            Status
            <select name="status" defaultValue={task.status}>
              <option value="todo">To Do</option>
              <option value="in-progress">In Progress</option>
              <option value="done">Done</option>
            </select>
          </label>
        </div>
        {actionData?.error && (
          <p style={{ color: "red" }}>{actionData.error}</p>
        )}
        <button type="submit" name="intent" value="save" disabled={isSubmitting}>
          {isSubmitting ? "Saving..." : "Save changes"}
        </button>
        <button
          type="submit"
          name="intent"
          value="delete"
          style={{ marginLeft: "0.5rem", color: "red" }}
          disabled={isSubmitting}
        >
          Delete task
        </button>
      </form>
    </section>
  );
}

This file:

  • Implements loaders and actions using React Router types for type safety.
  • Uses json/redirect helpers to return responses.
  • Exposes route components that read from useLoaderData and useActionData.

4. Register the tasks routes in the router

Update src/router.tsx to add tasks routes under /dashboard:

import { createBrowserRouter } from "react-router-dom";
import { AppLayout } from "./routes/AppLayout";
import { HomePage } from "./routes/HomePage";
import { AboutPage } from "./routes/AboutPage";
import { ContactPage } from "./routes/ContactPage";
import { NotFoundPage } from "./routes/NotFoundPage";
import { DashboardLayout } from "./routes/dashboard/DashboardLayout";
import { DashboardOverviewPage } from "./routes/dashboard/DashboardOverviewPage";
import { ProjectsPage } from "./routes/dashboard/ProjectsPage";
import { ProjectDetailsPage } from "./routes/dashboard/ProjectDetailsPage";
import {
  TaskDetailsRoute,
  taskDetailsAction,
  taskDetailsLoader,
  TasksListRoute,
  tasksListAction,
  tasksListLoader,
} from "./routes/dashboard/tasksRoutes";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <AppLayout />,
    errorElement: <NotFoundPage />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "about", element: <AboutPage /> },
      { path: "contact", element: <ContactPage /> },
      {
        path: "dashboard",
        element: <DashboardLayout />,
        children: [
          { index: true, element: <DashboardOverviewPage /> },
          { path: "projects", element: <ProjectsPage /> },
          { path: "projects/:projectId", element: <ProjectDetailsPage /> },
          {
            path: "tasks",
            loader: tasksListLoader,
            action: tasksListAction,
            element: <TasksListRoute />,
          },
          {
            path: "tasks/:taskId",
            loader: taskDetailsLoader,
            action: taskDetailsAction,
            element: <TaskDetailsRoute />,
          },
        ],
      },
    ],
  },
]);

Now open /dashboard/tasks and try:

  • Creating a new task with the form at the top
  • Clicking the generated link to view and edit a specific task
  • Updating or deleting tasks

Everything is handled by route-level data APIs, not ad-hoc useEffect calls.


5. Handling loading and error states

React Router's data APIs let you handle loading and errors more systematically.

5.1. Loading state with useNavigation

We already used useNavigation to show button text changes (e.g. Creating...). You can also:

  • Disable forms while loading
  • Show spinners or skeletons at the route level

For global loading indicators (e.g. top progress bar), you can use useNavigation inside AppLayout.

5.2. Error boundaries per route tree

You can add an errorElement to your dashboard branch (and even to tasks routes) to render a friendly error page when loaders or actions throw.

For example, in src/routes/dashboard/DashboardErrorBoundary.tsx:

import { isRouteErrorResponse, useRouteError } from "react-router-dom";

export const DashboardErrorBoundary: React.FC = () => {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <section>
        <h1>Dashboard Error</h1>
        <p>
          {error.status}{error.statusText}
        </p>
        {error.data?.message && <p>{error.data.message}</p>}
      </section>
    );
  }

  return (
    <section>
      <h1>Something went wrong in the dashboard.</h1>
      <p>Please try again or contact support.</p>
    </section>
  );
};

Then reference it in your router tree under dashboard as errorElement.


6. Best practices for data routing with TypeScript

A few guidelines as your app grows:

  • Keep UI and data logic close but separate: we collected loader/action logic into tasksRoutes.tsx, next to the views that consume it.
  • Use types all the way through: define Task and TaskStatus in a shared place and reuse them in store, loaders, and components.
  • Prefer loaders/actions over useEffect for fetching or mutating route-specific data. This centralizes logic and keeps components mostly declarative.
  • Use json and typed useLoaderData to avoid untyped any data flow.
  • Use redirect in actions to simplify navigation after mutations (e.g. after creating or deleting an item).

7. Recap and what’s next

You now have:

  • A tasks feature under /dashboard/tasks using loaders and actions
  • A simple CRUD flow: list, create, edit, delete
  • Structured error and loading handling via React Router APIs

In Part 4, we'll introduce authentication and protected routes:

  • A login page that simulates signing in
  • Protected dashboard routes that require auth
  • Redirects based on auth state (e.g. redirect to login when unauthenticated)
  • Role-based access for certain sections like /dashboard/admin
Share this article:

Related Articles

FSI

Full Stack Insights

Software Engineer

Passionate about software development, architecture, and sharing knowledge with the community.