DEV Community

PARTHIV SAIKIA
PARTHIV SAIKIA

Posted on

Building a Full-Stack App with Turborepo, React, and Hono (Part 3: Developing the UI)

In the previous two blog posts, we completed the project scaffolding and developed the APIs. In this installment, we will complete the project by building the user interface. As discussed previously, we will use React Router v7 for building the frontend.

Getting Started

Clone the repository by running this command in your terminal:

git clone https://github.com/parthivsaikia/todo-turbo
Enter fullscreen mode Exit fullscreen mode

Navigate to the todo-turbo folder:

cd todo-turbo
Enter fullscreen mode Exit fullscreen mode

Install and build all dependencies:

pnpm i && pnpm build
Enter fullscreen mode Exit fullscreen mode

Developing the Frontend with React Router v7

Overview of the UI

Our todo application will allow users to create, delete, and edit todos. The web page will have two main sections: one for user management and another for todo management.

The user section will include a form to create new users and a dropdown menu to select the active user. The todo section will feature a form to create new todos for the selected user, along with a list displaying all todos for that user. Each todo will have two action buttons: one to update the todo and another to delete it.

The application will utilize all of the API endpoints we built previously:

  • The user creation form will use POST /users to create new users
  • The dropdown menu will use GET /users to retrieve all users
  • The todo creation form will use POST /todos to create new todos
  • The todo list will use GET /todos/:userId to retrieve todos for a specific user
  • The update and delete buttons will use PUT /todos/:id and DELETE /todos/:id respectively

Let's begin.

Understanding the Frontend Package

From the root directory, run pnpm run dev and visit http://localhost:5173. You will see the welcome message from React Router.

React Router welcome message

All pages in React Router are defined in the app/routes directory. If you examine the app/routes directory, you will find one file: home.tsx. This file is responsible for rendering the message currently displayed.

Let's review this file:

import type { Route } from "./+types/home";
import { Welcome } from "../welcome/welcome";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "New React Router App" },
    { name: "description", content: "Welcome to React Router!" },
  ];
}

export default function Home() {
  return <Welcome />;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, it returns a component called Welcome. This component is imported from the welcome folder, which contains two SVGs for dark and light mode along with the Welcome component itself. Since we don't need any of these files, we can safely delete the welcome folder.

Run the following command from inside the todo-turbo/apps/frontend/app/ folder:

rm -rf welcome
Enter fullscreen mode Exit fullscreen mode

Since the Welcome component was imported in home.tsx, TypeScript will throw an error stating "Cannot find module '../welcome/welcome' or its corresponding type declarations."

TypeScript error

Simply remove that import line and remove the Welcome component from the return statement of the Home function component. Your home.tsx should now look like this:

import type { Route } from "./+types/home";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "New React Router App" },
    { name: "description", content: "Welcome to React Router!" },
  ];
}

export default function Home() {
  return <div></div>;
}
Enter fullscreen mode Exit fullscreen mode

Setting Up CORS

Before we start building our UI components, we need to configure CORS (Cross-Origin Resource Sharing) to allow our frontend to communicate with our backend. Since browsers enforce the same-origin policy for security reasons, this configuration is essential for cross-origin requests.

Open todo-turbo/apps/backend/src/index.ts and add the following lines:

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import userRouter from "./routes/user.js";
import todoRouter from "./routes/todo.js";

const app = new Hono();

// Configure CORS
app.use(
  cors({
    origin: "http://localhost:5173",
  }),
);

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.route("/users", userRouter);
app.route("/todos", todoRouter);

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  },
);
Enter fullscreen mode Exit fullscreen mode

With this configuration, our frontend running on port 5173 can now make requests to our backend running on port 3000.

Creating the Signup Page

Since we don't have any users yet, let's first create a signup page that will allow users to register. For the sake of simplicity, we're keeping the signup process straightforward without password authentication.

Create a new file signup.tsx in the todo-turbo/apps/frontend/app/routes/ folder. On this page, we will implement a simple Form component with an input field and a submit button. The input will capture the user's name and the button will submit the form.

import type { Route } from "./+types/signup";
import axios from "axios";
import { Form, redirect } from "react-router";

export async function clientAction({ request }: Route.ClientActionArgs) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);
  const response = await axios.post("http://localhost:3000/users", data);
  console.log(response.data);
  return redirect("/");
}

export default function SignupForm() {
  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-600 via-blue-600 to-indigo-700 flex items-center justify-center p-4">
      <div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 w-full max-w-md border border-white/20">
        <div className="text-center mb-8">
          <h1 className="text-4xl font-bold text-white mb-2">Create Account</h1>
        </div>
        <Form method="post" className="space-y-6">
          <div>
            <label htmlFor="name" className="block text-white text-sm font-medium mb-2">
              Username
            </label>
            <input
              type="text"
              id="name"
              name="name"
              required
              className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
              placeholder="Enter your username"
            />
          </div>
          <button
            type="submit"
            className="w-full bg-gradient-to-r from-purple-500 to-pink-500 text-white py-3 rounded-lg font-semibold hover:from-purple-600 hover:to-pink-600 transform hover:scale-105 transition shadow-lg"
          >
            Create User
          </button>
        </Form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's examine the clientAction function. We're using clientAction here because we have a separate backend with Hono. For more information about actions, you can refer to the React Router documentation.

First, we extract the formData, which contains the inputs we provided. Then we create an object from this formData, which will be sent as the payload to our API. We make a POST request to http://localhost:3000/users, which is the route we defined for creating new users. After receiving the response, we redirect the user to the home page.

We're importing three dependencies: redirect, axios, and the type Route. The redirect utility is built into React Router, but we need to install axios separately. From the frontend folder, run:

pnpm add axios
Enter fullscreen mode Exit fullscreen mode

Now add this route to the app/routes.ts file to configure it:

import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("/signup", "routes/signup.tsx"),
] satisfies RouteConfig;
Enter fullscreen mode Exit fullscreen mode

With the server running, visit http://localhost:5173/signup and you should see the signup form.

Signup form

Building the User Dropdown Component

Now let's create a dropdown menu for selecting users. We will use the Form component from React Router. It's good practice to create separate files for larger components, so let's create a new folder called components inside the todo-turbo/apps/frontend/app/ folder.

Create a new file userDropDown.tsx in this components folder.

First, let's define the User interface to ensure type safety:

import { Form } from "react-router";

interface User {
  id: string;
  name: string;
}

export default function UserDropDownMenu({
  users,
  selectedUser,
  onUserChange,
}: {
  users: User[];
  selectedUser: string | null;
  onUserChange: (userId: string) => void;
}) {
  // Filter out any null or undefined values from the users array
  const validUsers = users?.filter((user) => user != null) || [];

  // Handle the case when there are no valid users
  if (validUsers.length === 0) {
    return (
      <div className="space-y-4">
        <div className="bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-4">
          <p className="text-white/90 text-sm">
            No users found. Please create a user first to get started.
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <label htmlFor="user-select" className="block text-white text-sm font-medium">
        Select User
      </label>
      <select
        id="user-select"
        value={selectedUser || ""}
        onChange={(e) => onUserChange(e.target.value)}
        className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
      >
        <option value="" disabled>
          Choose a user
        </option>
        {validUsers.map((user) => (
          <option key={user.id} value={user.id}>
            {user.name}
          </option>
        ))}
      </select>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component accepts three props: an array of users, the currently selected user ID, and a callback function to handle user selection changes.

Handling the Empty Users Case: We've added protection against null or undefined values at multiple levels. First, we filter out any null or undefined values from the users array using users?.filter((user) => user != null) || []. This prevents the "TypeError: can't access property 'id', user is null" error that would occur if the array contains null elements. Then we check if there are any valid users remaining. If no valid users are available, the component displays a friendly message prompting the user to create an account first, instead of showing an empty dropdown. This provides better user experience and clear guidance.

When valid users are available, we iterate through the filtered array to render each user as an option in the dropdown.

Creating the Todo Form Component

Now let's create a form for adding new todos. Create a new file todoForm.tsx in the components folder:

import { Form } from "react-router";

export default function TodoForm({ userId }: { userId: string | null }) {
  if (!userId) {
    return (
      <div className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 border border-white/20 text-center">
        <p className="text-white/70">Please select a user to create todos</p>
      </div>
    );
  }

  return (
    <div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-6 border border-white/20">
      <h2 className="text-2xl font-bold text-white mb-6">Create New Todo</h2>
      <Form method="post" className="space-y-4">
        <input type="hidden" name="userId" value={userId} />
        <div>
          <label htmlFor="task" className="block text-white text-sm font-medium mb-2">
            Task
          </label>
          <input
            type="text"
            id="task"
            name="task"
            required
            className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
            placeholder="Enter your task"
          />
        </div>
        <div>
          <label htmlFor="dueDate" className="block text-white text-sm font-medium mb-2">
            Due Date
          </label>
          <input
            type="date"
            id="dueDate"
            name="dueDate"
            required
            className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
          />
        </div>
        <button
          type="submit"
          className="w-full bg-gradient-to-r from-green-500 to-emerald-500 text-white py-3 rounded-lg font-semibold hover:from-green-600 hover:to-emerald-600 transform hover:scale-105 transition shadow-lg"
        >
          Add Todo
        </button>
      </Form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component checks whether a user is selected before rendering the form. If no user is selected, it displays a message prompting the user to select one. The form includes inputs for the task description and due date, along with a hidden input field for the userId.

Creating the Todo List Component

Now let's create a component to display the list of todos. Create a new file todoList.tsx in the components folder:

import { Form } from "react-router";
import { useState } from "react";

interface Todo {
  id: string;
  task: string;
  dueDate: string;
  userId: string;
}

export default function TodoList({ todos }: { todos: Todo[] }) {
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editTask, setEditTask] = useState("");
  const [editDueDate, setEditDueDate] = useState("");

  const handleEdit = (todo: Todo) => {
    setEditingId(todo.id);
    setEditTask(todo.task);
    setEditDueDate(todo.dueDate.split("T")[0]);
  };

  const cancelEdit = () => {
    setEditingId(null);
    setEditTask("");
    setEditDueDate("");
  };

  if (todos.length === 0) {
    return (
      <div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20 text-center">
        <p className="text-white/70 text-lg">No todos yet. Create your first one!</p>
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <h2 className="text-2xl font-bold text-white mb-4">Your Todos</h2>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className="bg-white/10 backdrop-blur-lg rounded-xl p-6 border border-white/20 hover:bg-white/15 transition"
        >
          {editingId === todo.id ? (
            <Form method="post" className="space-y-4">
              <input type="hidden" name="intent" value="update" />
              <input type="hidden" name="id" value={todo.id} />
              <input
                type="text"
                name="task"
                value={editTask}
                onChange={(e) => setEditTask(e.target.value)}
                className="w-full px-3 py-2 rounded-lg bg-white/20 border border-white/30 text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
              />
              <input
                type="date"
                name="dueDate"
                value={editDueDate}
                onChange={(e) => setEditDueDate(e.target.value)}
                className="w-full px-3 py-2 rounded-lg bg-white/20 border border-white/30 text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
              />
              <div className="flex gap-2">
                <button
                  type="submit"
                  className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white py-2 rounded-lg font-semibold hover:from-green-600 hover:to-emerald-600 transition"
                >
                  Save
                </button>
                <button
                  type="button"
                  onClick={cancelEdit}
                  className="flex-1 bg-gradient-to-r from-gray-500 to-gray-600 text-white py-2 rounded-lg font-semibold hover:from-gray-600 hover:to-gray-700 transition"
                >
                  Cancel
                </button>
              </div>
            </Form>
          ) : (
            <div className="space-y-3">
              <div className="text-white text-lg font-medium">{todo.task}</div>
              <div className="text-white/70 text-sm">
                Due: {new Date(todo.dueDate).toLocaleDateString()}
              </div>
              <div className="flex gap-2 mt-4">
                <button
                  onClick={() => handleEdit(todo)}
                  className="flex-1 bg-gradient-to-r from-yellow-500 to-orange-500 text-white py-2 rounded-lg font-semibold hover:from-yellow-600 hover:to-orange-600 transition"
                >
                  Edit
                </button>
                <Form method="post" className="flex-1">
                  <input type="hidden" name="intent" value="delete" />
                  <input type="hidden" name="id" value={todo.id} />
                  <button
                    type="submit"
                    className="w-full bg-gradient-to-r from-red-500 to-rose-500 text-white py-2 rounded-lg font-semibold hover:from-red-600 hover:to-rose-600 transition"
                  >
                    Delete
                  </button>
                </Form>
              </div>
            </div>
          )}
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component displays all todos in a card layout. Each todo can be edited or deleted. When the user clicks the Edit button, the todo transforms into an editable form. We use React state to manage which todo is being edited and to store the temporary values during editing.

Completing the Home Page

Now let's bring everything together in the home page. We need to add a clientLoader to fetch users and todos, and a clientAction to handle todo creation, updates, and deletions.

Update your home.tsx:

import type { Route } from "./+types/home";
import axios from "axios";
import { Link, redirect } from "react-router";
import { useState } from "react";
import UserDropDownMenu from "../components/userDropDown";
import TodoForm from "../components/todoForm";
import TodoList from "../components/todoList";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Todo App - Home" },
    { name: "description", content: "Manage your todos efficiently" },
  ];
}

export async function clientLoader() {
  try {
    const response = await axios.get("http://localhost:3000/users");
    const users = await response.data;
    // Ensure we always return an array, even if the response is null or undefined
    return { users: users || [], todos: [] };
  } catch (error) {
    // If there's an error (like no users exist yet), return an empty array
    // instead of throwing an error
    console.error("Error fetching users:", error);
    return { users: [], todos: [] };
  }
}

export async function clientAction({ request }: Route.ClientActionArgs) {
  const formData = await request.formData();
  const intent = formData.get("intent");

  try {
    if (intent === "update") {
      const id = formData.get("id");
      const task = formData.get("task");
      const dueDate = formData.get("dueDate");
      await axios.put(`http://localhost:3000/todos/${id}`, {
        task,
        dueDate: new Date(dueDate as string).toISOString(),
      });
    } else if (intent === "delete") {
      const id = formData.get("id");
      await axios.delete(`http://localhost:3000/todos/${id}`);
    } else {
      // Create new todo
      const task = formData.get("task");
      const dueDate = formData.get("dueDate");
      const userId = formData.get("userId");
      await axios.post("http://localhost:3000/todos", {
        task,
        dueDate: new Date(dueDate as string).toISOString(),
        userId,
      });
    }
    return redirect("/");
  } catch (error) {
    const errorMessage =
      error instanceof Error
        ? `Error processing todo: ${error.message}`
        : `Unknown error processing todo`;
    throw new Error(errorMessage);
  }
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const [selectedUser, setSelectedUser] = useState<string | null>(null);
  const [todos, setTodos] = useState([]);

  const handleUserChange = async (userId: string) => {
    setSelectedUser(userId);
    try {
      const response = await axios.get(`http://localhost:3000/todos/${userId}`);
      setTodos(response.data);
    } catch (error) {
      console.error("Error fetching todos:", error);
      setTodos([]);
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-600 via-blue-600 to-indigo-700 p-8">
      <div className="max-w-7xl mx-auto">
        <div className="text-center mb-12">
          <h1 className="text-5xl font-bold text-white mb-4">Todo Manager</h1>
          <p className="text-white/80 text-lg">Organize your tasks efficiently</p>
        </div>

        <div className="grid md:grid-cols-2 gap-8 mb-8">
          <div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
            <h2 className="text-2xl font-bold text-white mb-6">User Management</h2>
            <UserDropDownMenu
              users={loaderData.users}
              selectedUser={selectedUser}
              onUserChange={handleUserChange}
            />
            <Link
              to="/signup"
              className="mt-4 block w-full text-center bg-gradient-to-r from-purple-500 to-pink-500 text-white py-3 rounded-lg font-semibold hover:from-purple-600 hover:to-pink-600 transform hover:scale-105 transition shadow-lg"
            >
              Create New User
            </Link>
          </div>

          <TodoForm userId={selectedUser} />
        </div>

        <TodoList todos={todos} />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's understand what's happening here:

  1. clientLoader: Fetches all users when the page loads and initializes the component with the necessary data. We've updated this to gracefully handle the case when no users exist yet by returning an empty array instead of throwing an error. This ensures the application doesn't crash on first load when the database is empty.
  2. clientAction: Handles three types of actions: creating, updating, and deleting todos. We use an "intent" field to distinguish between these actions
  3. State Management: We use React state to track the selected user and their todos, ensuring the UI remains responsive
  4. handleUserChange: When a user is selected from the dropdown, this function fetches their todos from the backend and updates the state

The UI is now organized into two main sections:

  • User management section at the top with the dropdown and "Create New User" button
  • Todo management section below with the form and list displayed side by side

Additionally, the TodoList component is rendered below these sections to display all todos for the selected user. The layout uses a grid system where the user management and todo form are side by side on medium and larger screens, with the todo list spanning the full width below them.

Testing the Application

Now let's test the complete application. Ensure both your frontend and backend servers are running:

  1. From the root directory, run pnpm run dev
  2. Visit http://localhost:5173
  3. Click "Create New User" to create a few users
  4. Select a user from the dropdown menu
  5. Create some todos for that user
  6. Try editing and deleting todos

Your complete todo application is now functional. The application fetches data from your Hono backend, displays it in an attractive interface, and enables full CRUD operations on both users and todos.

What's Next?

Congratulations! You've successfully built a full-stack monorepo application using Turborepo, React Router v7, and Hono. Here are some ideas to enhance the application further:

  • Add authentication with JWT tokens for secure user sessions
  • Implement todo completion status with visual indicators
  • Add todo categories or tags for better organization
  • Implement todo filtering and sorting capabilities
  • Add due date notifications to remind users of upcoming tasks
  • Implement todo priority levels to help users focus on important tasks
  • Add a dark/light mode toggle for user preference

The complete source code for this section is available on GitHub.

Conclusion

This concludes our three-part series on building a full-stack application with Turborepo, React Router, and Hono. You now have a solid foundation for building scalable monorepo applications with modern tools and industry best practices. Happy coding!

Top comments (0)