Skip to content

React Project – Build a Complete Event App with Auth & Go REST API Integration

Published:

In this tutorial we will build the frontend for a modern event management app using React, Tailwind CSS, and React Router. We will focus on using the most important features of React that you will need to build a modern web application.

Table of contents

Open Table of contents

📱 What We’ll Build

By the end of this series, you’ll have a fully functional frontend where users can:

React Event App landing page React Event App details page

Project Setup

We will use Vite for this project. If you are not familiar with Vite, it is a build tool that let’s you create modern web applications with ease. It generates a project structure based on the template you choose. Comes with a dev server that automatically reloads the page when you make changes to the code. Gives you a optimized production build when you want to deoploy with a single command.

✅ Step 1: Create a Vite Project

We’ll begin by creating a new Vite app we will use the React template with TypeScript. We will also use the SWC compiler for faster build times.

npm create vite@latest event-app -- --template react-swc-ts
cd event-app
npm install

Start the development server to confirm everything is working:

npm run dev

If you visit http://localhost:5173 you should see the default Vite page.

Vite default page

🎨 Install dependencies

We will use Tailwind CSS for styling and React Router for routing. We will also use clsx for conditional classes and lucide-react for icons.

npm install tailwindcss @tailwindcss/vite react-router-dom clsx lucide-react

The idea is to keep the dependencies to a minimum and focus on core functionality of React.

📄 Configure Tailwind CSS

Update your vite.config.ts file to enable Tailwind in all relevant files:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()], // add tailwindcss plugin
});

Remove the default styles from src/index.css and src/App.css.

Now, update your src/index.css file to include Tailwind’s base styles:

@import "tailwindcss";

Update app.tsx remove all the code and add the following:

function App() {
  return (
    <h1 className="text-3xl font-bold text-blue-600 underline">Event app</h1>
  );
}

export default App;

If you now visit http://localhost:5173 you should see the text “Event app” in blue with additional styling.

📁 Set Up Project Structure

To keep things organized, let’s create some new folders in src/:

src/
├── api/          // API request helpers
├── components/   // Reusable UI components
├── pages/        // Route-based pages (Login, Register, Events)
├── utils/        // Utility functions (e.g., auth, formatting)
├── hooks/        // Custom hooks
├── contexts/     // Contexts for authentication

This structure will make it easier to scale and maintain the app as we build more features.

Changes made to the api

If you been following along with creating the API Rest API in Gin, I had to do some changes to the API to make it work with the frontend.

React Event App Source code

Don’t worry about these API changes if you’re only building the frontend. This tutorial focuses on the frontend part, and we’ll be using the API as our data source.

Running the API locally

If you have not already, you can run the API locally by following the instructions in the Rest API in Gin.

To be able to run the API locally, you need to have Go installed. You can download it from here.

  1. Clone the repository
git clone [email protected]:patni1992/Rest-api-in-go-gin.git
  1. Change directory to the repository
cd Rest-api-in-go-gin
  1. Run migrations
go run ./cmd/migrate up
  1. Run the API
go run ./cmd/api

it should start running on port 8080.

Setup React Router

Inside src/app.tsx we will setup the React Router. Add the following code:

import {
  BrowserRouter as Router,
  Routes,
  Route,
  Navigate,
} from "react-router-dom";
import { EventsPage } from "./pages/EventsPage";
import { MyEventsPage } from "./pages/MyEventsPage";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { EventDetailsPage } from "./pages/EventDetailsPage";
import { EditEventPage } from "./pages/EditEventPage";

function App() {
  return (
    <div className="min-h-screen bg-gray-50">
      <Router>
        <main className="container mx-auto px-4 py-8">
          <Routes>
            <Route path="/" element={<EventsPage />} />
            <Route path="/my-events" element={<MyEventsPage />} />
            <Route path="/events/:eventId" element={<EventDetailsPage />} />
            <Route path="/events/:eventId/edit" element={<EditEventPage />} />
            <Route path="/login" element={<LoginPage />} />
            <Route path="/register" element={<RegisterPage />} />
            <Route path="*" element={<Navigate to="/" replace />} />
          </Routes>
        </main>
      </Router>
    </div>
  );
}

export default App;

We create a BrowserRouter to wrap the app and the Routes to define the routes. The Route component is used to define the path and the element to render when the path is matched. So when the user visits for example /my-events the MyEventsPage component will be rendered. The Navigate component is used to redirect to a different path. We use this to redirect to the home page if the user visits a non-existing path.

Let’s create the pages for the routes.

// src/pages/EventsPage.tsx
export function EventsPage() {
  return <div>EventsPage</div>;
}

// src/pages/MyEventsPage.tsx
export function MyEventsPage() {
  return <div>MyEventsPage</div>;
}

// src/pages/EventDetailsPage.tsx
export function EventDetailsPage() {
  return <div>EventDetailsPage</div>;
}

// src/pages/EditEventPage.tsx
export function EditEventPage() {
  return <div>EditEventPage</div>;
}

// src/pages/LoginPage.tsx
export function LoginPage() {
  return <div>LoginPage</div>;
}

// src/pages/RegisterPage.tsx
export function RegisterPage() {
  return <div>RegisterPage</div>;
}

If you now open the browser and try to navigate to the different pages for example /events. You should see the page content.

📄 Creating the Header Component

Currently we have no navigation. Let’s create a simple header. Inside src/components/header/Header.tsx add the following code:

import { Link } from "react-router-dom";

export function Header() {
  return (
    <header className="flex items-center justify-between bg-white p-4 shadow">
      <h1 className="text-xl font-bold">Event App</h1>
      <nav className="flex items-center gap-4">
        <ul className="flex gap-4">
          <li>
            <Link to="/">All Events</Link>
          </li>
          <li>
            <Link to="/my-events">My Events</Link>
          </li>
          <li>
            <Link to="/login">Login</Link>
          </li>
          <li>
            <Link to="/register">Register</Link>
          </li>
        </ul>
      </nav>
    </header>
  );
}

Here we are using the Link component to navigate to the different pages. The Link component from React Router provides client-side navigation without page reloads, unlike regular <a> tags.

Let’s update the App.tsx file to include the Header component.

import { Header } from "./components/header/Header";

function App() {
  return (
    <div className="min-h-screen bg-gray-50">
      <Router>
        <Header />
        ... rest of the code
      </Router>
    </div>
  );
}

The Header will be rendered in all pages and we can navigate to the different pages by clicking the links in the header.

todo: add image

🔐 Authentication with React Context

In this section, we’ll set up authentication for our app using React Context. This will allow us to manage user login and logout.

What is React Context?

React Context lets you share data between any components in your app without manually passing it through each level. It’s like having a central storage that any component can access directly.

Setting Up Authentication Context

First, let’s create two files:

  1. src/contexts/AuthContext.tsx - Defines our context and hook
  2. src/contexts/AuthProvider.tsx - Provides the authentication state

Creating the Context

In src/contexts/AuthContext.tsx add the following code:

import { createContext, useContext } from "react";
import type { AuthUser } from "../types";

interface AuthContextType {
  auth: AuthUser | null;
  login: (user: AuthUser) => void;
  logout: () => void;
  isAuthenticated: boolean;
}

export const AuthContext = createContext<AuthContextType | null>(null);

export const useAuth = () => {
  const context = useContext(AuthContext);

  if (context === null) {
    throw new Error("useAuth must be used within an AuthProvider");
  }

  return context;
};

This code sets up the foundation for our authentication system:

  1. Interface - Defines what authentication data will be available (user info, login/logout functions, auth status)
  2. Context - Creates an empty “storage container” for our authentication data
  3. Custom Hook - Provides an easy way for components to access auth data with useAuth()

Note: This just creates the structure - the actual values and logic are implemented in the AuthProvider component below.

Creating the Provider

In src/contexts/AuthProvider.tsx add the following code:

import { useState } from "react";
import { useEffect } from "react";
import type { AuthUser } from "../types";
import { AuthContext } from "./AuthContext";

interface AuthProviderProps {
  children: React.ReactNode;
}

export const AuthProvider = ({ children }: AuthProviderProps) => {
  const [auth, setAuth] = useState<AuthUser | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const loadAuthUser = () => {
      const savedUser = localStorage.getItem("auth");
      if (savedUser) {
        try {
          setAuth(JSON.parse(savedUser));
        } catch (error) {
          console.error("Error parsing saved user:", error);
          localStorage.removeItem("auth");
        }
      }
      setLoading(false);
    };

    loadAuthUser();
  }, []);

  const login = (userData: AuthUser) => {
    setAuth(userData);
    localStorage.setItem("auth", JSON.stringify(userData));
  };

  const logout = () => {
    setAuth(null);
    localStorage.removeItem("auth");
  };

  if (loading) {
    return null;
  }

  const value = {
    auth,
    login,
    logout,
    isAuthenticated: !!auth,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

The AuthProvider component is the heart of our authentication system. It manages the user’s authentication state and provides it to all child components. When the app starts, it checks localStorage for any saved user data. It also provides login and logout functions that update both the state and localStorage. Any component below this provider in the component tree can access the authentication state and these functions using the useAuth hook.

We will also create a type for the auth user.

Create a new file:
src/types.ts

export interface AuthUser {
  userId: number;
  token: string;
}

This is what we will get back from the API when the user logs in.

Adding auth provider to the app

Inside the src/App.tsx file, we need to add the auth provider to the app.

Add the following code:

import {
  BrowserRouter as Router,
  Routes,
  Route,
  Navigate,
} from "react-router-dom";
import { EventsPage } from "./pages/EventsPage";
import { MyEventsPage } from "./pages/MyEventsPage";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { AuthProvider } from "./contexts/AuthProvider";
import { EventDetailsPage } from "./pages/EventDetailsPage";
import { EditEventPage } from "./pages/EditEventPage";
import { Header } from "./components/header/Header";

function App() {
  return (
    <AuthProvider>
      <div className="min-h-screen bg-gray-50">
        <Router>
          <Header />
          <main className="container mx-auto px-4 py-8">
            <Routes>
              <Route path="/" element={<EventsPage />} />
              <Route path="/my-events" element={<MyEventsPage />} />
              <Route path="/events/:eventId" element={<EventDetailsPage />} />
              <Route path="/events/:eventId/edit" element={<EditEventPage />} />
              <Route path="/login" element={<LoginPage />} />
              <Route path="/register" element={<RegisterPage />} />
              <Route path="*" element={<Navigate to="/" replace />} />
            </Routes>
          </main>
        </Router>
      </div>
    </AuthProvider>
  );
}

export default App;

Benefits of Using Context for Auth

  1. Global Access: Any component can access the auth state without prop drilling
  2. Consistent State: The auth state is managed in one place
  3. Persistence: User stays logged in even after page refresh
  4. Type Safety: TypeScript ensures we use the auth functions correctly

API client

We will use the built in fetch library to make requests to the API. To make it easier to use, we will create a custom wrapper.

Create a new file:
src/api/api.ts

export const API_BASE_URL =
  import.meta.env.VITE_API_BASE_URL || "http://localhost:8080/api";

export const apiFetch = async (
  url: string,
  options: RequestInit = {}
): Promise<Response> => {
  const auth = localStorage.getItem("auth");
  const token = auth ? JSON.parse(auth).token : null;

  const defaultHeaders = {
    "Content-Type": "application/json",
    Accept: "application/json",
  };

  const headers = token
    ? {
        ...defaultHeaders,
        Authorization: `Bearer ${token}`,
      }
    : defaultHeaders;

  return window.fetch(url, {
    ...options,
    headers,
  });
};

This API client sets up a base URL and wraps the fetch API with authentication handling. It automatically adds JSON headers and, if available, includes the user’s auth token from localStorage.

Login and Register Pages

Before we start building the login and register pages, we will create some components that we will use in the pages.

Creating the Button Component

To be consistent with the styling & reduce code duplication, we will create a button component.

Create a new file:
src/components/Button.tsx

import clsx from "clsx";

const buttonClasses = {
  base: "rounded-md cursor-pointer disabled:opacity-50",
  variants: {
    primary: "bg-blue-600 hover:bg-blue-700 text-white",
    secondary:
      "border border-gray-300 text-gray-700 hover:bg-gray-50 hover:text-gray-900",
  },
  sizes: {
    default: "px-4 py-2",
    small: "px-3 py-1 text-sm",
  },
};

export function Button({
  children,
  icon,
  variant = "primary",
  size = "default",
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
  icon?: React.ReactNode;
  variant?: "primary" | "secondary";
  size?: "default" | "small";
}) {
  return (
    <button
      className={clsx(
        buttonClasses.base,
        buttonClasses.variants[variant],
        buttonClasses.sizes[size],
        icon && "flex items-center gap-2"
      )}
      {...props}
    >
      {icon}
      {children}
    </button>
  );
}

We setup some basic styling for the button. It also comes with different variants and sizes. To keep the classnames clean we use the clsx library.

Creating the InputGroup Component

We will also create a input component that we will used in forms.

Create a new file:
src/components/InputGroup.tsx

import type { ChangeEvent } from "react";

interface InputGroupProps {
  name: string;
  label: string;
  value: string;
  onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
  error?: string;
  type?: string;
  isTextArea?: boolean;
  rows?: number;
  className?: string;
}

export function InputGroup({
  name,
  label,
  value,
  onChange,
  error,
  type = "text",
  isTextArea = false,
  rows = 3,
  className = "",
}: InputGroupProps) {
  const InputType = isTextArea ? "textarea" : "input";

  return (
    <div className={className}>
      <label
        htmlFor={name}
        className="mb-1 block text-sm font-medium text-gray-700"
      >
        {label}
      </label>
      <InputType
        autoComplete="off"
        id={name}
        name={name}
        value={value}
        onChange={onChange}
        rows={isTextArea ? rows : undefined}
        type={isTextArea ? undefined : type}
        className={`w-full rounded-md border px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 ${
          error ? "border-red-500" : "border-gray-300"
        }`}
      />
      {error && <p className="mt-1 text-sm text-red-500">{error}</p>}
    </div>
  );
}

The purpose of this component is to make sure input fields have consistent styling. We also reduce the amount of code in forms.

Creating the useForm Hook

To make it easier to manage form state and validation, we will create a custom hook.

Create a new file:
src/hooks/useForm.tsx

Add the following code:

import { useState, useEffect } from "react";
import type { ChangeEvent, FormEvent } from "react";

export function useForm<T>({
  initialValues,
  validate,
  onSubmit,
}: {
  initialValues: T;
  validate?: (values: T) => Partial<T>;
  onSubmit: (values: T) => Promise<void> | void;
}) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<T>>({});
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setValues(initialValues);
  }, [initialValues]);

  const handleChange = (
    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
    setErrors(prev => ({ ...prev, [name]: null }));
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setError(null);

    if (validate) {
      const newErrors = validate(values);
      setErrors(newErrors);

      if (Object.keys(newErrors).length > 0) {
        return;
      }
    }

    setIsLoading(true);
    try {
      await onSubmit(values);
    } catch (err) {
      setError(err instanceof Error ? err : new Error("Something went wrong"));
    } finally {
      setIsLoading(false);
    }
  };

  const reset = () => {
    setValues(initialValues);
    setErrors({});
    setError(null);
  };

  return {
    values,
    errors,
    error,
    isLoading,
    handleChange,
    handleSubmit,
    reset,
  };
}

The useForm hook is a custom hook that handles all the common form tasks:

This saves us from writing the same form logic in every form component.

Creating the ErrorMessage Component

Create a new file:
src/components/ErrorMessage.tsx

export function ErrorMessage({ error }: { error: Error | null | string }) {
  if (!error) return null;
  return (
    <div className="mb-4 rounded-md bg-red-50 p-3 text-red-600">
      {error instanceof Error ? error.message : error}
    </div>
  );
}

A reusable error component that shows error messages in a consistent style. By keeping it in one place, we can easily update the look and feel of all error messages across the app.

Creating the LoadingMessage Component

Create a new file:
src/components/LoadingMessage.tsx

Add the following code:

export function LoadingMessage({ message }: { message: string }) {
  return (
    <div className="flex justify-center py-10">
      <div className="animate-pulse text-gray-500">{message}</div>
    </div>
  );
}

This is a simple component that will be used to show a loading message.

Register page

We are now ready to build the register page.

Open the file:
src/pages/RegisterPage.tsx

Remove all the code and add the following:

import { Link, useNavigate } from "react-router-dom";
import { register } from "../api/auth";
import { Button } from "../components/Button";
import { InputGroup } from "../components/InputGroup";
import { useForm } from "../hooks/useForm";
import { ErrorMessage } from "../components/ErrorMessage";

interface RegisterFormData {
  name: string;
  email: string;
  password: string;
}

const initialValues: RegisterFormData = {
  name: "",
  email: "",
  password: "",
};

export function RegisterPage() {
  const navigate = useNavigate();

  const validateForm = (values: RegisterFormData) => {
    const errors: Partial<RegisterFormData> = {};
    if (!values.name) errors.name = "Name is required";
    if (!values.email) errors.email = "Email is required";
    if (values.password.length < 8)
      errors.password = "Password must be at least 8 characters long";

    return errors;
  };

  const onSubmit = async (values: RegisterFormData) => {
    await register(values.name, values.email, values.password);
    navigate("/login");
  };

  const { values, errors, error, isLoading, handleChange, handleSubmit } =
    useForm<RegisterFormData>({
      initialValues,
      validate: validateForm,
      onSubmit,
    });

  return (
    <div className="mx-auto max-w-md">
      <div className="rounded-lg bg-white p-6 shadow-md">
        <h2 className="mb-6 text-2xl font-semibold text-gray-800">Register</h2>

        {error && <ErrorMessage error={error} />}

        <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
          <InputGroup
            label="Name"
            name="name"
            value={values.name}
            onChange={handleChange}
            error={errors.name}
          />

          <InputGroup
            label="Email"
            name="email"
            type="email"
            value={values.email}
            onChange={handleChange}
            error={errors.email}
          />

          <InputGroup
            label="Password"
            name="password"
            type="password"
            value={values.password}
            onChange={handleChange}
            error={errors.password}
          />

          <div className="flex flex-col space-y-3">
            <Button type="submit" disabled={isLoading}>
              {isLoading ? "Registering..." : "Register"}
            </Button>

            <div className="text-center text-sm text-gray-600">
              Already have an account?{" "}
              <Link to="/login" className="text-blue-600 hover:text-blue-800">
                Login
              </Link>
            </div>
          </div>
        </form>
      </div>
    </div>
  );
}

This RegisterPage component creates a user registration form with the following features:

  1. Form Fields: It includes three input fields:

    • Name field for the user’s full name
    • Email field for the user’s email address
    • Password field for the user’s password
  2. Form Validation: The form includes validation rules:

    • Name and email are required fields
    • Password must be at least 8 characters long
    • Error messages are displayed below each field if validation fails
  3. Form Handling:

    • Uses a custom useForm hook to manage form state and submission
    • Clicking the register button will call the register function from the api.
    • Automatically redirects to the login page after successful registration

Creating the register api call

Create a new file:
src/api/auth.ts

Add the following code:

import type { User } from "../types";
import { API_BASE_URL, apiFetch } from "./api";

export const register = async (
  name: string,
  email: string,
  password: string
): Promise<User | null> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/auth/register`, {
      method: "POST",
      body: JSON.stringify({ name, email, password }),
    });

    if (!response.ok) {
      return null;
    }

    return await response.json();
  } catch (error) {
    console.error("Registration error:", error);
    return null;
  }
};

Here we are making a POST request to the /v1/auth/register endpoint. We are sending the name, email, and password in the body of the request.

If the request is successful, we will receive a user object in the response. If the request fails, we will return null.

Now we just need to create a user type.

Inside the src/types.ts file, add the following code:

export interface User {
  id: number;
  name: string;
  email: string;
}

Login page

Open the file:
src/pages/LoginPage.tsx

Remove all the code and add the following:

import { Link, useNavigate, useLocation } from "react-router-dom";
import { login } from "../api/auth";
import { useAuth } from "../contexts/AuthContext";
import { InputGroup } from "../components/InputGroup";
import { useForm } from "../hooks/useForm";
import { Button } from "../components/Button";
import { ErrorMessage } from "../components/ErrorMessage";

type LoginForm = {
  email: string;
  password: string;
};

const initialValues: LoginForm = {
  email: "",
  password: "",
};

export function LoginPage() {
  const { login: authLogin } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from?.pathname || "/";

  const validate = (values: LoginForm) => {
    const errors: Partial<LoginForm> = {};
    if (!values.email) errors.email = "Email is required";
    if (!values.password) errors.password = "Password is required";
    return errors;
  };

  const {
    values: formData,
    errors,
    error,
    isLoading,
    handleChange,
    handleSubmit,
  } = useForm<LoginForm>({
    initialValues,
    validate,
    onSubmit: async values => {
      const user = await login(values.email, values.password);
      if (user) {
        authLogin(user);
        navigate(from, { replace: true });
      } else {
        throw new Error("Invalid email or password");
      }
    },
  });

  return (
    <div className="mx-auto max-w-md">
      <div className="rounded-lg bg-white p-6 shadow-md">
        <h2 className="mb-6 text-2xl font-semibold text-gray-800">Login</h2>

        {error && <ErrorMessage error={error} />}

        <form className="flex flex-col gap-6" onSubmit={handleSubmit}>
          <InputGroup
            error={errors.email}
            name="email"
            label="Email"
            value={formData.email}
            onChange={handleChange}
          />

          <InputGroup
            error={errors.password}
            name="password"
            label="Password"
            value={formData.password}
            onChange={handleChange}
            type="password"
          />

          <div className="flex flex-col space-y-3">
            <Button disabled={isLoading}>
              {isLoading ? "Logging in..." : "Login"}
            </Button>

            <div className="text-center text-sm text-gray-600">
              Don't have an account?{" "}
              <Link
                to="/register"
                className="text-blue-600 hover:text-blue-800"
              >
                Register
              </Link>
            </div>
          </div>
        </form>
      </div>
    </div>
  );
}

This LoginPage component creates a user login form with the following features:

  1. Form Fields: It includes two input fields:

    • Email field for the user’s email address
    • Password field for the user’s password
  2. Form Validation: The form includes basic validation:

    • Both email and password are required fields
    • Error messages are displayed below each field if validation fails
  3. Authentication Flow:

    • Uses the useAuth hook to manage authentication state
    • Redirects users back to their original destination after successful login
    • Handles failed login attempts with clear error messages
    • Shows loading state during the login process

    Now we need to create the login api call.

Inside this file src/api/auth.ts

Add the following code:

import type { AuthUser, User } from "../types";
import { API_BASE_URL, apiFetch } from "./api";

export const login = async (
  email: string,
  password: string
): Promise<AuthUser | null> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/auth/login`, {
      method: "POST",
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      return null;
    }

    return await response.json();
  } catch (error) {
    console.error("Login error:", error);
    return null;
  }
};
// ... rest of the code

Here we are making a POST request to the /v1/auth/login endpoint. We are sending the email and password in the body of the request.

If the request is successful, we will receive an auth user object in the response. If the request fails, we will return null.

Testing registering a new user and logging in

Now we can test the register and login pages.

  1. Go to the register page and register a new user, after registering you should be redirected to the login page.
  2. Login with the new user.
  3. You should be redirected to the events page.
  4. If we inspect localStorage we should see the token and userId.

Protecting routes & logging out

Protecting routes

For my events page we want to protect the route so only authenticated users can access it. To do this we will create a protected route component.

Create a new file:
src/components/ProtectedRoute.tsx

Add the following code:

import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";

interface ProtectedRouteProps {
  children: React.ReactNode;
}

export function ProtectedRoute({ children }: ProtectedRouteProps) {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
}

Here we are using the useAuth hook to check if the user is authenticated. If the user is not authenticated, we redirect them to the login page else we render the protected route.

Inside the src/App.tsx file, we need to add the protected route to the my events page.

Add the following code:

import { ProtectedRoute } from "./components/ProtectedRoute";

<Route
  path="/my-events"
  element={
    <ProtectedRoute>
      <MyEventsPage />
    </ProtectedRoute>
  }
/>;

Logging out

If the user is logged in, we want to add a logout button to the header.

Inside the src/components/header/Header.tsx file, we need to add a logout button.

Update it to the following:

import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { Button } from "../Button";

export function Header() {
  const { isAuthenticated, logout } = useAuth();
  const navigate = useNavigate();

  const handleLogout = () => {
    logout();
    navigate("/");
  };

  return (
    <header className="flex items-center justify-between bg-white p-4 shadow">
      <h1 className="text-xl font-bold">Event App</h1>
      <nav className="flex items-center gap-4">
        <ul className="flex gap-4">
          <li>
            <Link to="/">All Events</Link>
          </li>
          <li>
            <Link to="/my-events">My Events</Link>
          </li>
          {isAuthenticated ? (
            <li>
              <Button variant="secondary" size="small" onClick={handleLogout}>
                Logout
              </Button>
            </li>
          ) : (
            <>
              <li>
                <Link to="/login">Login</Link>
              </li>
              <li>
                <Link to="/register">Register</Link>
              </li>
            </>
          )}
        </ul>
      </nav>
    </header>
  );
}

We are using the useAuth hook to check if the user is authenticated. If the user is authenticated, we show the logout button. If the user is not authenticated, we show the login and register links.

When the user clicks the logout button, we call the logout function and redirect them to the home page using the useNavigate hook.

If we now go to the my events page we should see my events page and if we click the logout button and try to access the page again we should be redirected to the login page.

📄 Creating the Events Page

Creating the EventsPage component

When the user visits the events page we want to fetch the events from the API and display them.

Open the file:
src/pages/EventsPage.tsx

Remove all the code and add the following:

import { fetchEvents } from "../api/events";
import { EventList } from "../components/events/EventList";
import type { EventData } from "../types";
import { useFetch } from "../hooks/useFetch";

export function EventsPage() {
  const { data, loading, error } = useFetch<EventData[]>(fetchEvents);
  const events = data ?? [];

  return (
    <>
      <div className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-semibold text-gray-800">Events</h1>
      </div>

      <EventList
        loading={loading}
        error={error?.message ?? null}
        events={events}
      />
    </>
  );
}

We are using the useFetch hook to fetch the events from the API, this hook is a custom hook that we will create in the next section. We pass the data to the EventList component. This component will also be used in the my events page.

Createting the useFetch hook & fetchEvents api call

Create a new file:
src/hooks/useFetch.tsx

Add the following code:

import { useState, useEffect, useCallback } from "react";

type UseFetchResult<T> = {
  data: T | null;
  error: Error | null;
  loading: boolean;
  refetch: () => void;
};

export function useFetch<T>(fetcher: () => Promise<T>): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const result = await fetcher();
      setData(result);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  }, [fetcher]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { data, error, loading, refetch: fetchData };
}

The useFetch hook is a custom hook that fetches data from the API and returns the data, error, loading state and a function to refetch the data. The purpose of this hook is to avoid repeating the same code in every page that fetches data from the API.

If you would use a library like react-query, under the hood it would be doing something similar to this.

Now we need to create the fetchEvents api call.

Inside the src/api folder, create a new file:
src/api/events.ts

With the following code:

import type { EventData } from "../types";
import { API_BASE_URL, apiFetch } from "./api";

export const fetchEvents = async (): Promise<EventData[]> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/events`);

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error("Error fetching events:", error);
    throw error;
  }
};

We are using the helpers we created earlier and then we are making a GET request to the /v1/events endpoint.

If the request is successful, we will receive an array of events in the response. If the request fails, we will throw an error.

Creating the types for the events

Inside the src/types.ts file, add the following code:

export interface EventData {
  id: number;
  name: string;
  description: string;
  date: string;
  location: string;
  ownerId: number;
}

This is the type for the events that we will get back from the API.

If we now go to the events page it should say no events found. This is because we don’t have any events in the database.

Creating the EventList component

Inside the src/components folder create a new folder called events and inside it create a new file called EventList.tsx

Add the following code:

import { useNavigate } from "react-router-dom";
import { EventCard } from "./EventCard";
import type { Event } from "../../types";
import { LoadingMessage } from "../LoadingMessage";
import { ErrorMessage } from "../ErrorMessage";

interface EventListProps {
  events: EventData[];
  loading: boolean;
  error: string | null;
}

export function EventList({
  events,
  loading = false,
  error = null,
}: EventListProps) {
  const navigate = useNavigate();
  const handleEventClick = (eventId: number) => navigate(`/events/${eventId}`);

  if (loading) return <LoadingMessage message="Loading events..." />;

  if (error) return <ErrorMessage error={error} />;

  if (events.length === 0)
    return (
      <div className="rounded-lg bg-white p-6 text-center text-gray-500 shadow">
        No events found.
      </div>
    );

  return (
    <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
      {events.map(event => (
        <EventCard key={event.id} event={event} onClick={handleEventClick} />
      ))}
    </div>
  );
}

We start by defining the props that the component will receive. It will receive the events, loading state and error state.

We check if the loading state is true in that case we return a loading message. We do the same for the error state.

If the events array is empty we return a message saying no events found.

If we have a array of events we map over them and return an EventCard component for each event. We pass the event to the EventCard component and the onEventClick function so we can navigate to the event details page when the user clicks on an event.

Creating the EventCard component

Create a new file:
src/components/events/EventCard.tsx

With this code:

import { formatDate } from "../../utils/dateUtils";
import { MapPinIcon, CalendarIcon } from "lucide-react";
import type { EventData } from "../../types";

interface EventCardProps {
  event: EventData;
  onClick: (eventId: number) => void;
}

export function EventCard({ event, onClick }: EventCardProps) {
  return (
    <div
      className="flex h-full cursor-pointer flex-col overflow-hidden rounded-lg bg-white shadow transition-shadow duration-300 hover:shadow-md"
      onClick={() => onClick(event.id)}
    >
      <div className="flex flex-1 flex-col p-5">
        <h3 className="mb-2 text-lg font-medium text-gray-800">{event.name}</h3>

        <p className="mb-4 line-clamp-2 text-sm text-gray-600">
          {event.description}
        </p>

        <div className="mb-2 flex items-center text-sm text-gray-500">
          <CalendarIcon className="mr-2 h-4 w-4" />
          <p>{formatDate(event.date)}</p>
        </div>

        <div className="flex items-center text-sm text-gray-500">
          <MapPinIcon className="mr-2 h-4 w-4" />
          <p>{event.location}</p>
        </div>
      </div>
    </div>
  );
}

We use tailwind to style the component to make it look nice. We then display the details of the event. To show some icons we use the lucide-react library. When the user clicks on the event card we navigate to the event details page. Since the date is not really human readable we use a helper function to format it.

Inside the src/utils folder create a new file called dateUtils.ts with the following code:

export const formatDate = (dateString: string): string => {
  const date = new Date(dateString);

  if (isNaN(date.getTime())) {
    return "Invalid date format";
  }

  return date.toLocaleDateString(navigator.language, {
    weekday: "short",
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
};

We parse the date string to a date object if it is not a valid date we return a message saying invalid date format. Otherwise we return the date in a human readable format.

📄 Showing my events data

Since we are using the same EventList component in the my events page we can reuse it.

Update the src/pages/MyEventsPage.tsx file to the following:

import { fetchUserEvents } from "../api/attendees";
import { useAuth } from "../contexts/AuthContext";
import type { EventData } from "../types";
import { EventList } from "../components/events/EventList";
import { useFetch } from "../hooks/useFetch";
import { useCallback } from "react";

export function MyEventsPage() {
  const { auth } = useAuth();
  const getUserEvents = useCallback(
    () => fetchUserEvents(auth?.userId),
    [auth]
  );

  const { data: events, loading, error } = useFetch<EventData[]>(getUserEvents);

  return (
    <div>
      <h1 className="mb-6 text-2xl font-semibold text-gray-800">
        Events I'm Attending
      </h1>
      <EventList
        loading={loading}
        error={error?.message ?? null}
        events={events || []}
      />
    </div>
  );
}

This page is very similar to the events page. We are using the same EventList component and the same useFetch hook but with a different api call.

To fetch the events the user is attending we first need to get the user id from the auth context. We then pass the user id to the fetchUserEvents api call.

We wrap the getUserEvents function with useCallback to prevent it from being recreated on every render. useCallback is a React hook that remembers a function between renders, only creating a new version when its dependencies change. Without this, the function would change every time the component re-renders, which would trigger our useFetch hook to run again, creating an endless loop of API calls.

All we need to do now is to create the fetchUserEvents api call. Inside the src/api folder create a new file called attendees.ts with the following code:

import type { EventData } from "../types";
import { API_BASE_URL, apiFetch } from "./api";

export const fetchUserEvents = async (
  userId: number | undefined
): Promise<EventData[]> => {
  if (!userId) {
    return [];
  }

  try {
    const response = await apiFetch(
      `${API_BASE_URL}/v1/attendees/${userId}/events`
    );

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error(`Error fetching events for user ${userId}:`, error);
    throw error;
  }
};

We are making a GET request to the /v1/attendees/${userId}/events endpoint and we are passing the user id to the endpoint.

If we now go to the my events page you should see a message saying no events found. This is because we are not attending any events yet. We will add the ability for a event owner to add attendees to their event later.

Creating events

Inside the events page we want to add a button to create a new event.

Update the EventsPage component to add a button to create a new event.

import { useState } from "react";
import { fetchEvents } from "../api/events";
import { EventList } from "../components/events/EventList";
import { AddEventModal } from "../components/events/AddEventModal";
import { useAuth } from "../contexts/AuthContext";
import type { EventData } from "../types";
import { Button } from "../components/Button";
import { PlusIcon } from "lucide-react";
import { useFetch } from "../hooks/useFetch";

export function EventsPage() {
  const { isAuthenticated } = useAuth();
  const [isAddEventModalOpen, setIsAddEventModalOpen] = useState(false);
  const { data, loading, error, refetch } = useFetch<EventData[]>(fetchEvents);
  const events = data ?? [];

  const handleAddEvent = () => {
    refetch();
    setIsAddEventModalOpen(false);
  };

  return (
    <>
      <div className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-semibold text-gray-800">Events</h1>
        <div className="flex items-center space-x-4">
          {isAuthenticated && (
            <Button
              icon={<PlusIcon className="mr-1 h-4 w-4" />}
              onClick={() => setIsAddEventModalOpen(true)}
            >
              Add Event
            </Button>
          )}
        </div>
      </div>

      <EventList
        loading={loading}
        error={error?.message ?? null}
        events={events}
      />

      {isAddEventModalOpen && isAuthenticated && (
        <AddEventModal
          isOpen={isAddEventModalOpen}
          onClose={() => setIsAddEventModalOpen(false)}
          onAddEvent={handleAddEvent}
        />
      )}
    </>
  );
}

We create a state to keep track of the add event modal open state. This modal will contain a form to create a new event.

Next we have a button to open the modal. When the user clicks the button we set the isAddEventModalOpen state to true. This button is only visible if the user is authenticated.

Next we have the AddEventModal component. This component is a modal that will contain a form to create a new event.

Let’s create the AddEventModal component.

Create a new file:
src/components/events/AddEventModal.tsx

Add the following code:

import { XIcon } from "lucide-react";
import { createEvent } from "../../api/events";
import type { EventData } from "../../types";
import { InputGroup } from "../InputGroup";
import { Button } from "../Button";
import { useForm } from "../../hooks/useForm";

interface AddEventModalProps {
  isOpen: boolean;
  onClose: () => void;
  onAddEvent: (event: EventData) => void;
}

interface EventFormData {
  name: string;
  description: string;
  date: string;
  location: string;
}

const initialValues: EventFormData = {
  name: "",
  description: "",
  date: "",
  location: "",
};

export function AddEventModal({
  isOpen,
  onClose,
  onAddEvent,
}: AddEventModalProps) {
  const validate = (values: EventFormData) => {
    const errors: Partial<EventFormData> = {};
    if (values.name.length < 3)
      errors.name = "Name must be at least 3 characters";
    if (values.description.length < 10)
      errors.description = "Description must be at least 10 characters";
    if (!values.date.length) errors.date = "Date is required";
    if (values.location.length < 3)
      errors.location = "Location must be at least 3 characters";
    console.log("errors", errors);
    return errors;
  };

  const onSubmit = async (values: EventFormData) => {
    const date = new Date(values.date);
    const formattedValues = {
      ...values,
      date: date.toISOString(),
    };
    const newEvent = await createEvent(formattedValues);
    onAddEvent(newEvent);
    reset();
    onClose();
  };

  const { values, errors, isLoading, handleChange, handleSubmit, reset } =
    useForm({
      initialValues,
      validate,
      onSubmit,
    });

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/60 p-4"
      onClick={onClose}
    >
      <div
        className="w-full max-w-md rounded-lg bg-white shadow-lg"
        onClick={e => e.stopPropagation()}
      >
        <div className="flex items-center justify-between border-b border-gray-200 p-4">
          <h2 className="text-lg font-semibold text-gray-800">Add New Event</h2>

          <Button variant="secondary" onClick={onClose}>
            <XIcon className="h-5 w-5" />
          </Button>
        </div>

        <form onSubmit={handleSubmit} className="p-4">
          <div className="space-y-4">
            <InputGroup
              name="name"
              label="Name"
              value={values.name}
              onChange={handleChange}
              error={errors.name}
            />

            <InputGroup
              name="description"
              label="Description"
              value={values.description}
              onChange={handleChange}
              error={errors.description}
              isTextArea
            />

            <InputGroup
              name="date"
              type="datetime-local"
              label="Date and Time"
              value={values.date}
              onChange={handleChange}
              error={errors.date}
            />

            <InputGroup
              name="location"
              label="Location"
              value={values.location}
              onChange={handleChange}
              error={errors.location}
            />
          </div>

          <div className="mt-6 flex justify-end space-x-2">
            <Button variant="secondary" onClick={onClose}>
              Cancel
            </Button>
            <Button type="submit" disabled={isLoading}>
              {isLoading ? "Creating..." : "Create Event"}
            </Button>
          </div>
        </form>
      </div>
    </div>
  );
}

We start by creating some interfaces for the form data and the props that the component will receive. We also create an initial values object for the form data.

To validate the form data we create a validate function. We check length of the input fields and make sure the date is not empty.

Then we have the onSubmit function. This function is called when the user submits the form. We parse the date to a ISO string and then we call the createEvent api call, then we reset the form and close the modal.

After that we use the useForm hook to handle the form data and validation. We pass the initial values, validate function and onSubmit function. We destructure the values, errors, isLoading, handleChange, handleSubmit and reset from the hook.

If the modal is not open we just return null.

We have our modal container. It dims the background and centers the modal. We also stop the event from bubbling up to the parent container. This is because we want to close the modal when the user clicks outside of it however we want to keep the modal open when the user clicks submit and the form has errors.

After that we use the input group component to create the form fields. We pass the state from the useForm hook to the inputs. Then we have a submit & cancel button.

Lets add the api call to create a new event. Inside the src/api/events.ts file add the following code:

export const createEvent = async (
  event: Omit<EventData, "id" | "ownerId">
): Promise<EventData> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/events`, {
      method: "POST",
      body: JSON.stringify(event),
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error("Error creating event:", error);
    throw error;
  }
};

Here we are using the Omit type to omit the id and ownerId properties from the event object. We are not sending these properties to the API. We do post the event object to the API and the API will return the created event with the id and ownerId.

We can now go to the events page and click the add event button. We should see a modal to create a new event.

If we fill out the form and click create event we should see the new event in the events list.

📄 Showing event details

When we click on an event card we want to see the details of the event.

Open the file:
src/pages/EventDetailsPage.tsx

Remove all the code and add the following:

import { useCallback } from "react";
import {
  fetchEventAttendees,
  removeAttendeeFromEvent,
  addAttendeeToEvent,
} from "../api/attendees";
import { deleteEvent, fetchEventById } from "../api/events";
import { fetchUsers } from "../api/users";
import { useAuth } from "../contexts/AuthContext";
import type { EventData, User } from "../types";
import { useParams, useNavigate } from "react-router-dom";
import { AddAttendeeModal } from "../components/attendees/AddAttendeeModal";
import { CalendarIcon, MapPinIcon, PencilIcon, TrashIcon } from "lucide-react";
import { formatDate } from "../utils/dateUtils";
import { LoadingMessage } from "../components/LoadingMessage";
import { AttendeeSection } from "../components/attendees/AttendeeSection";
import { Button } from "../components/Button";
import { useFetch } from "../hooks/useFetch";
import { useState } from "react";
import { ErrorMessage } from "../components/ErrorMessage";
import { useAsync } from "../hooks/useAsync";
import { ConfirmModal } from "../components/ConfirmModal";

export function EventDetailsPage() {
  const { auth } = useAuth();
  const { eventId } = useParams();
  const navigate = useNavigate();
  const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
  const [isDeleteConfirmModalOpen, setIsDeleteConfirmModalOpen] =
    useState(false);

  const getEvent = useCallback(
    () => fetchEventById(Number(eventId)),
    [eventId]
  );

  const getAttendees = useCallback(
    () => fetchEventAttendees(Number(eventId)),
    [eventId]
  );

  const {
    data: currentEvent,
    loading: eventLoading,
    error: eventError,
  } = useFetch<EventData>(getEvent);

  const {
    data: attendees,
    refetch: refetchAttendees,
    error: attendeesError,
  } = useFetch<User[]>(getAttendees);

  const getUsersIfAuthenticated = useCallback(
    () => (auth ? fetchUsers() : Promise.resolve([])),
    [auth]
  );

  const {
    data: users,
    loading: usersLoading,
    error: usersError,
  } = useFetch<User[]>(getUsersIfAuthenticated);

  const { run: deleteEventAction, error: deleteEventError } = useAsync({
    action: deleteEvent,
    onSuccess: () => navigate("/events"),
    errorMessage: "Failed to delete event",
  });

  const { run: removeAttendeeAction, error: removeError } = useAsync({
    action: removeAttendeeFromEvent,
    onSuccess: refetchAttendees,
    errorMessage: "Failed to remove attendee",
  });

  const { run: addAttendeeAction, error: addError } = useAsync({
    action: addAttendeeToEvent,
    onSuccess: refetchAttendees,
    errorMessage: "Failed to add attendee",
  });

  const error =
    eventError ||
    attendeesError ||
    usersError ||
    removeError ||
    addError ||
    deleteEventError;

  if (eventLoading) {
    return <LoadingMessage message="Loading..." />;
  }

  const currentAttendees = attendees || [];

  return (
    <div className="mx-auto my-8 flex w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg ">
      {error && <ErrorMessage error={error} />}
      <div className="flex flex-col-reverse gap-4 border-b border-gray-200 p-6 md:flex-row md:items-start md:justify-between">
        <div className="min-w-0 flex-1">
          <h2 className="max-w-xl break-words text-xl font-semibold text-gray-800">
            {currentEvent?.name}
          </h2>
          <p className="mt-1 max-w-xl whitespace-pre-wrap break-words text-gray-600">
            {currentEvent?.description}
          </p>
        </div>
        {auth?.userId === currentEvent?.ownerId && (
          <div className="mb-4 flex flex-shrink-0 gap-2 md:mb-0">
            <Button
              size="small"
              variant="secondary"
              icon={<TrashIcon className="h-4 w-4" />}
              onClick={() => setIsDeleteConfirmModalOpen(true)}
            >
              Delete
            </Button>
            <Button
              size="small"
              variant="secondary"
              icon={<PencilIcon className="h-4 w-4" />}
              onClick={() => navigate(`/events/${eventId}/edit`)}
            >
              Edit
            </Button>
          </div>
        )}
      </div>

      <div className="min-h-[400px] flex-1 p-6">
        <div className="mb-6">
          <h3 className="mb-3 text-lg font-medium text-gray-800">
            Event Details
          </h3>
          <div className="space-y-3">
            <div className="flex items-center text-gray-600">
              <CalendarIcon className="mr-3 h-5 w-5" />
              <span>{formatDate(currentEvent?.date || "")}</span>
            </div>
            <div className="flex items-center text-gray-600">
              <MapPinIcon className="mr-3 h-5 w-5" />
              <span>{currentEvent?.location}</span>
            </div>
          </div>
        </div>

        <AttendeeSection
          attendees={currentAttendees}
          isOwner={auth?.userId === currentEvent?.ownerId}
          setIsAddAttendeeModalOpen={setIsAddAttendeeModalOpen}
          handleRemoveAttendee={attendeeId =>
            removeAttendeeAction(attendeeId, Number(eventId))
          }
        />
      </div>

      <AddAttendeeModal
        isOpen={isAddAttendeeModalOpen}
        isLoading={usersLoading}
        eventId={currentEvent?.id || 0}
        existingAttendees={currentAttendees}
        users={users || []}
        onClose={() => setIsAddAttendeeModalOpen(false)}
        onAdd={userId => addAttendeeAction(Number(eventId), userId)}
      />

      <ConfirmModal
        isOpen={isDeleteConfirmModalOpen}
        title="Delete Event"
        description={`Are you sure you want to delete "${currentEvent?.name}"?`}
        confirmButtonText="Delete"
        onConfirm={() => deleteEventAction(Number(eventId))}
        onClose={() => setIsDeleteConfirmModalOpen(false)}
      />
    </div>
  );
}

This is probably the most complex page in our app. The reason for this is that we are fetching multiple pieces of data from the API. We are fetching the event details, the attendees, the users and we also have api calls to add and remove attendees. However the code should still be readable and hopefully not to hard to understand.

After we setup all the api calls with our custom hooks We display the details of the event. We also display the all the attendees for this event. Then we have buttons to edit and delete the event. We also have two modals one for adding attendees and one for confirming the delete event action.

useAsync hook and api calls

To handle api calls that triggers an action but don’t use a full form we are gonna create a custom hook. To reduce code duplication.

Inside the src/hooks folder create a new file called useAsync.ts with the following code:

import { useState } from "react";

export function useAsync<Data, Params extends unknown[]>(config: {
  action: (...params: Params) => Promise<Data>;
  onSuccess?: () => void;
  errorMessage?: string;
}) {
  const [error, setError] = useState<Error | null>(null);

  const run = async (...params: Params) => {
    try {
      setError(null);
      await config.action(...params);
      if (config.onSuccess) {
        await config.onSuccess();
      }
    } catch (err) {
      setError(
        err instanceof Error
          ? err
          : new Error(config.errorMessage ?? "Operation failed")
      );
    }
  };

  return { run, error, setError };
}

This will call the api call and handle the error. We can also call a function after the api call is successful.

Now we have 6 new api calls we need to create.

Inside the src/api/attendees.ts file add the following code:

export const fetchEventAttendees = async (eventId: number): Promise<User[]> => {
  try {
    const response = await apiFetch(
      `${API_BASE_URL}/v1/events/${eventId}/attendees`
    );

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error(`Error fetching attendees for event ${eventId}:`, error);
    throw error;
  }
};

export const addAttendeeToEvent = async (
  eventId: number,
  userId: number
): Promise<void> => {
  try {
    const response = await apiFetch(
      `${API_BASE_URL}/v1/events/${eventId}/attendees/${userId}`,
      {
        method: "POST",
      }
    );

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }
  } catch (error) {
    console.error(
      `Error adding attendee ${userId} to event ${eventId}:`,
      error
    );
    throw error;
  }
};

export const removeAttendeeFromEvent = async (
  attendeeId: number,
  eventId: number
): Promise<void> => {
  try {
    const response = await apiFetch(
      `${API_BASE_URL}/v1/events/${eventId}/attendees/${attendeeId}`,
      {
        method: "DELETE",
      }
    );

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }
  } catch (error) {
    console.error(
      `Error removing attendee ${attendeeId} from event ${eventId}:`,
      error
    );
    throw error;
  }
};

Don’t forget to add the import for the User type.

import type { User } from "../types";

These api calls are very similar to previous api calls we have created. With the only diffrence the data we pass and the api endpoints.

Let’s now create deleteEvent & fetchEventById.

Inside the src/api/events.ts file add the following code:

export const deleteEvent = async (eventId: number): Promise<void> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/events/${eventId}`, {
      method: "DELETE",
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }
  } catch (error) {
    console.error(`Error deleting event ${eventId}:`, error);
    throw error;
  }
};

export const fetchEventById = async (eventId: number): Promise<EventData> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/events/${eventId}`);
    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error(`Error fetching event ${eventId}:`, error);
    throw error;
  }
};

Now we only have fetchUsers left to create.

Create a new file:
src/api/users.ts

Add the following code:

import type { User } from "../types";
import { API_BASE_URL, apiFetch } from "./api";

export const fetchUsers = async (): Promise<User[]> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/users`);

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error("Error fetching users:", error);
    throw error;
  }
};

Attendee section

When viewing an event we want to show the attendees for that event. Start by creating a new folder in the src/components folder called Attendees.

Inside the Attendees folder create a new file called AttendeeSection.tsx

Add the following code:

import type { User } from "../../types";
import { AttendeeHeader } from "./AttendeeHeader";
import { AttendeeList } from "./AttendeeList";

export function AttendeeSection({
  attendees,
  isOwner,
  setIsAddAttendeeModalOpen,
  handleRemoveAttendee,
}: {
  attendees: User[];
  isOwner: boolean;
  setIsAddAttendeeModalOpen: (isOpen: boolean) => void;
  handleRemoveAttendee: (id: number) => void;
}) {
  return (
    <div>
      <AttendeeHeader
        attendees={attendees}
        isOwner={isOwner}
        setIsAddAttendeeModalOpen={setIsAddAttendeeModalOpen}
      />
      <AttendeeList
        attendees={attendees}
        isOwner={isOwner}
        onRemoveAttendee={handleRemoveAttendee}
      />
    </div>
  );
}

This component is a container for the attendee header and list. We pass the attendees, isOwner, setIsAddAttendeeModalOpen and handleRemoveAttendee to the component.

Attendee header

In this componenet we will display the number of attendees and the button to add an attendee.

Create a new file:
src/components/Attendees/AttendeeHeader.tsx

Add the following code:

import { PlusIcon } from "lucide-react";
import type { User } from "../../types";
import { Button } from "../Button";

interface AttendeeHeaderProps {
  attendees: User[];
  isOwner: boolean;
  setIsAddAttendeeModalOpen: (isOpen: boolean) => void;
}

export function AttendeeHeader({
  attendees,
  isOwner,
  setIsAddAttendeeModalOpen,
}: AttendeeHeaderProps) {
  return (
    <div className="mb-1 flex items-center justify-between">
      <h3 className="text-lg font-medium text-gray-800">
        {attendees.length > 0 ? "Attendees" : "No attendees yet"}{" "}
        <span className="text-sm text-gray-500">({attendees.length})</span>
      </h3>
      {isOwner && (
        <Button
          variant="secondary"
          size="small"
          icon={<PlusIcon className="h-4 w-4" />}
          onClick={() => setIsAddAttendeeModalOpen(true)}
        >
          Add Attendee
        </Button>
      )}
    </div>
  );
}

We display the number of attendees and the button to add an attendee. If the user is the owner of the event we show the button to add an attendee.

Attendee list

Create a new file:
src/components/Attendees/AttendeeList.tsx

Add the following code:

import type { User } from "../../types";
import { XIcon, CheckIcon } from "lucide-react";
import { Button } from "../Button";
import clsx from "clsx";

interface AttendeeListProps {
  attendees: User[];
  mode?: "display" | "selection";
  isOwner?: boolean;
  selectedAttendee?: User | null;
  onAttendeeSelect?: (attendee: User) => void;
  onRemoveAttendee?: (id: number) => void;
}

export function AttendeeList({
  attendees = [],
  mode = "display",
  isOwner,
  selectedAttendee,
  onAttendeeSelect = () => {},
  onRemoveAttendee = () => {},
}: AttendeeListProps) {
  if (attendees.length === 0) return null;

  return (
    <div>
      <ul className="h-full divide-y divide-gray-200 pb-6">
        {attendees.map(({ id, name, email }) => (
          <li
            key={id}
            className={clsx(
              "group flex items-center justify-between py-4 hover:bg-gray-50",
              mode === "selection" && {
                "cursor-pointer": true,
                "bg-blue-50": selectedAttendee?.id === id,
              }
            )}
            onClick={() =>
              mode === "selection" && onAttendeeSelect({ id, name, email })
            }
          >
            <div>
              <p className="font-medium text-gray-800">{name}</p>
              <p className="text-sm text-gray-600">{email}</p>
            </div>
            {mode === "display" && isOwner && onRemoveAttendee && (
              <Button
                size="small"
                className="mr-4 cursor-pointer rounded-full p-1.5 opacity-0 hover:bg-red-50 group-hover:opacity-100"
                onClick={() => onRemoveAttendee(id)}
                variant="secondary"
              >
                <XIcon className="h-5 w-5 text-gray-400 hover:text-red-500" />
              </Button>
            )}
            {mode === "selection" && selectedAttendee?.id === id && (
              <CheckIcon className="mr-4 h-5 w-5 text-blue-600" />
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

This component is a list of attendees. We display the name and email of the attendee. If the user is the owner of the event we show a button to remove the attendee. If the user is in selection mode we show a check icon if the attendee is selected. This is because we will use this component in the add attendee modal.

Add attendee modal

When clicking the add attendee button we want to show a modal with a list of users. It shoulder be possible to search for a user. We also only want to show users that are not already attending the event.

Let’s start with the search bar.

This will be used to filter the users.

Create a new file:
src/components/SearchBar.tsx

Add the following code:

import { SearchIcon } from "lucide-react";
import type { ChangeEventHandler } from "react";

export function SearchBar({
  value,
  onChange,
}: {
  value: string;
  onChange: ChangeEventHandler<HTMLInputElement>;
}) {
  return (
    <div className="border-b border-gray-200 py-4">
      <div className="relative">
        <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
          <SearchIcon className="h-4 w-4 text-gray-400" />
        </div>
        <input
          type="text"
          placeholder="Search users..."
          className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
          value={value}
          onChange={onChange}
        />
      </div>
    </div>
  );
}

It’s a simple input component with a search icon. It takes 2 props value and onChange.

Now we need to create the modal that will be used to add an attendee.

Create a new file:
src/components/Attendees/AddAttendeeModal.tsx

This modal component allows event owners to add new attendees to their events. It includes several key features:

Add the following code:

import { useState } from "react";
import { XIcon } from "lucide-react";
import type { User } from "../../types";
import { LoadingMessage } from "../LoadingMessage";
import { Button } from "../Button";
import { SearchBar } from "../SearchBar";
import { AttendeeList } from "./AttendeeList";

interface AddAttendeeModalProps {
  isOpen: boolean;
  onClose: () => void;
  eventId: number;
  existingAttendees: User[];
  users: User[];
  isLoading: boolean;
  onAdd: (userId: number) => Promise<void>;
}

export function AddAttendeeModal({
  isOpen,
  onClose,
  existingAttendees,
  users,
  isLoading,
  onAdd,
}: AddAttendeeModalProps) {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedUser, setSelectedUser] = useState<User | null>(null);
  const [isAdding, setIsAdding] = useState(false);

  const availableUsers = users.filter(
    user => !existingAttendees.some(attendee => attendee.id === user.id)
  );

  const filteredUsers =
    searchTerm.trim() === ""
      ? availableUsers
      : availableUsers.filter(
          user =>
            user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
            user.email.toLowerCase().includes(searchTerm.toLowerCase())
        );

  const handleAddAttendee = async (userToAdd: User) => {
    try {
      setIsAdding(true);
      await onAdd(userToAdd.id);
    } finally {
      setIsAdding(false);
    }
  };

  const handleClose = () => {
    setSearchTerm("");
    setSelectedUser(null);
    onClose();
  };

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/60 p-4"
      onClick={onClose}
    >
      <div
        className="flex h-full max-h-[90vh] w-full max-w-xl flex-col overflow-hidden rounded-lg bg-white p-4 shadow-lg"
        onClick={e => e.stopPropagation()}
      >
        <div className="flex items-center justify-between border-b border-gray-200 py-4">
          <h2 className="text-lg font-semibold text-gray-800">Add Attendee</h2>
          <Button size="small" variant="secondary" onClick={onClose}>
            <XIcon className="h-5 w-5" />
          </Button>
        </div>

        <SearchBar
          value={searchTerm}
          onChange={e => setSearchTerm(e.target.value)}
        />

        <div className="flex-1 overflow-y-auto p-1">
          {isLoading ? (
            <LoadingMessage message="Loading..." />
          ) : (
            <AttendeeList
              attendees={filteredUsers}
              mode="selection"
              selectedAttendee={selectedUser}
              onAttendeeSelect={setSelectedUser}
            />
          )}
        </div>

        <div className="flex justify-end space-x-2 border-t border-gray-200 p-4">
          <Button variant="secondary" onClick={handleClose}>
            Cancel
          </Button>
          <Button
            onClick={() => selectedUser && handleAddAttendee(selectedUser)}
            disabled={!selectedUser || isAdding}
          >
            {isAdding ? "Adding..." : "Add"}
          </Button>
        </div>
      </div>
    </div>
  );
}
  1. User Filtering: Only shows users who aren’t already attending the event
  2. Search Functionality: Users can search by name or email to quickly find people
  3. Selection Mode: Users can select someone from the list before adding them
  4. Loading States: Shows loading indicators during API calls

The component manages local state for the search term, selected user, and loading states. It filters the available users based on existing attendees and the search input, making it easy to find and add new people to events.

Confirm delete event modal

When deleting an event we want to show a modal to confirm the action.

Create a new file:
src/components/ConfirmModal.tsx

Add the following code:

import { Button } from "./Button";

interface ConfirmModalProps {
  isOpen: boolean;
  title: string;
  description: string;
  confirmButtonText: string;
  cancelButtonText?: string;
  onConfirm: () => void;
  onClose: () => void;
}

export function ConfirmModal({
  isOpen,
  title,
  description,
  confirmButtonText,
  cancelButtonText = "Cancel",
  onConfirm,
  onClose,
}: ConfirmModalProps) {
  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/60 p-4"
      onClick={onClose}
    >
      <div
        className="w-full max-w-md rounded-lg bg-white shadow-lg"
        onClick={e => e.stopPropagation()}
      >
        <div className="flex items-center justify-between border-b border-gray-200 p-4">
          <h2 className="text-lg font-semibold text-gray-800">{title}</h2>
        </div>

        <div className="p-4">
          <p className="mb-6 text-gray-600">{description}</p>
          <div className="flex justify-end space-x-2">
            <Button variant="secondary" onClick={onClose}>
              {cancelButtonText}
            </Button>
            <Button
              variant="primary"
              onClick={() => {
                onConfirm();
                onClose();
              }}
            >
              {confirmButtonText}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

This is a simple modal component with a title, description, confirm button and cancel button.

You show now be able to see the details of the event. Add & remove attendees if you are the owner of the event and delete the event.

Edit event page

The last page we need to create is the edit event page. This page allows event owners to modify their existing events.

Create a new file:
src/pages/EditEventPage.tsx

Add the following code:

import { useForm } from "../hooks/useForm";
import { CheckIcon } from "lucide-react";
import { InputGroup } from "../components/InputGroup";
import { Button } from "../components/Button";
import { formatDateForInput } from "../utils/dateUtils";
import { useNavigate, useParams } from "react-router-dom";
import { fetchEventById, updateEvent } from "../api/events";
import { useFetch } from "../hooks/useFetch";
import type { EventData } from "../types";
import { useCallback, useState, useMemo } from "react";
import { LoadingMessage } from "../components/LoadingMessage";
import { ErrorMessage } from "../components/ErrorMessage";

export function EditEventPage() {
  const { eventId } = useParams();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const navigate = useNavigate();

  const getEvent = useCallback(
    () => fetchEventById(Number(eventId)),
    [eventId]
  );

  const {
    data: currentEvent,
    loading: isLoading,
    error,
  } = useFetch<EventData>(getEvent);

  const initialValues = useMemo(
    () => ({
      name: currentEvent?.name || "",
      description: currentEvent?.description || "",
      date: formatDateForInput(currentEvent?.date || ""),
      location: currentEvent?.location || "",
    }),
    [currentEvent]
  );

  const validate = (values: typeof initialValues) => {
    const errors: Partial<typeof initialValues> = {};
    if (values.name.length < 3)
      errors.name = "Name must be at least 3 characters";
    if (values.description.length < 10)
      errors.description = "Description must be at least 10 characters";
    if (!values.date) errors.date = "Date is required";
    if (values.location.length < 3)
      errors.location = "Location must be at least 3 characters";
    return errors;
  };

  const handleSubmit = async (values: typeof initialValues) => {
    if (!currentEvent?.id) return;
    setIsSubmitting(true);
    try {
      const date = new Date(values.date);
      const formattedValues = {
        ...values,
        date: date.toISOString(), // Full ISO string with timezone: YYYY-MM-DDThh:mm:ss.sssZ
      };
      await updateEvent(currentEvent.id, formattedValues);
      navigate(`/events/${currentEvent.id}`);
    } catch (error) {
      console.error("Failed to update event:", error);
    } finally {
      setIsSubmitting(false);
    }
  };

  const {
    values,
    errors,
    handleChange,
    handleSubmit: onFormSubmit,
  } = useForm({
    initialValues,
    validate,
    onSubmit: handleSubmit,
  });

  if (isLoading) {
    return <LoadingMessage message="Loading event..." />;
  }

  if (error) {
    return <ErrorMessage error={error} />;
  }

  return (
    <div className="mx-auto my-8 flex h-[600px] w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
      <div className="border-b border-gray-200 p-6">
        <h2 className="text-xl font-semibold text-gray-800">Edit Event</h2>
      </div>

      <div className="flex-1 overflow-y-auto p-6">
        <form
          id="edit-event-form"
          onSubmit={onFormSubmit}
          className="space-y-4"
        >
          <InputGroup
            name="name"
            label="Name"
            value={values.name}
            onChange={handleChange}
            error={errors.name}
          />
          <InputGroup
            name="description"
            label="Description"
            value={values.description}
            onChange={handleChange}
            isTextArea
            rows={3}
            error={errors.description}
          />
          <InputGroup
            name="date"
            label="Date and Time"
            value={values.date}
            onChange={handleChange}
            type="datetime-local"
            error={errors.date}
          />
          <InputGroup
            name="location"
            label="Location"
            value={values.location}
            onChange={handleChange}
            error={errors.location}
          />
        </form>
      </div>

      <div className="flex justify-between border-t border-gray-200 p-4">
        <Button
          variant="secondary"
          type="button"
          onClick={() => {
            navigate(`/events/${currentEvent?.id}`);
          }}
        >
          Cancel
        </Button>
        <Button
          type="submit"
          form="edit-event-form"
          icon={<CheckIcon className="mr-1 h-4 w-4" />}
          disabled={isSubmitting}
        >
          {isSubmitting ? "Saving..." : "Save Changes"}
        </Button>
      </div>
    </div>
  );
}

The component first fetches the current event data, then creates initial form values from that data. It uses the same validation rules as the create event functionality to ensure consistency across the app.

  1. Data Pre-population: Fetches existing event data and populates the form fields
  2. Optimized Re-renders: Uses useMemo to prevent unnecessary form resets when the component re-renders
  3. Form Integration: Reuses our custom useForm hook with validation
  4. Loading States: Shows loading indicators while fetching event data
  5. Error Handling: Displays appropriate error messages for failed operations

We need to create the updateEvent api call. Inside the src/api update the events.ts file with the following code:

export const updateEvent = async (
  eventId: number,
  eventData: Omit<EventData, "id" | "attendees" | "ownerId">
): Promise<EventData> => {
  try {
    const response = await apiFetch(`${API_BASE_URL}/v1/events/${eventId}`, {
      method: "PUT",
      body: JSON.stringify(eventData),
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error(`Error updating event ${eventId}:`, error);
    throw error;
  }
};

Then we need to add a helper function to format the date for the input. Inside the src/utils folder update the dateUtils.ts file with the following code:

export const formatDateForInput = (dateString: string) => {
  const date = new Date(dateString);
  if (isNaN(date.getTime())) return "";

  // Format as YYYY-MM-DDThh:mm
  return new Date(date.getTime() - date.getTimezoneOffset() * 60000)
    .toISOString()
    .slice(0, 16);
};

This function formats the date that is returned from the input to the correct format. ` You should now be able to edit an event try it out.

Updating header styling & mobile responsiveness

Currently the headers styling is not great and it’s not responsive. Let’s fix that.

Update the src/components/Header.tsx file with the following code:

import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { Menu, X, CalendarIcon, UserIcon } from "lucide-react";
import { useState } from "react";
import clsx from "clsx";
import { Button } from "../Button";

const navItems = [
  { to: "/", label: "All Events", icon: CalendarIcon },
  { to: "/my-events", label: "Attending", icon: UserIcon },
];

export function Header() {
  const { logout, isAuthenticated } = useAuth();
  const navigate = useNavigate();
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
  const closeMobileMenu = () => setIsMobileMenuOpen(false);

  const handleLogout = () => {
    logout();
    navigate("/");
    closeMobileMenu();
  };

  const getNavLinkClass = (isActive: boolean, isMobile: boolean) =>
    clsx(
      "flex items-center text-sm transition-colors",
      isActive
        ? "text-blue-600 font-medium"
        : "text-gray-600 hover:text-gray-900",
      isMobile && "py-2 rounded-md"
    );

  const NavigationLinks = ({ isMobile = false }) => (
    <>
      {navItems.map(({ to, label, icon: Icon }) => (
        <NavLink
          key={to}
          to={to}
          onClick={isMobile ? closeMobileMenu : undefined}
          className={({ isActive }) => getNavLinkClass(isActive, isMobile)}
        >
          <Icon className="mr-2 h-4 w-4" />
          {label}
        </NavLink>
      ))}
    </>
  );

  const AuthButtons = ({ isMobile = false }) => {
    const baseButtonClass = clsx(
      "text-sm font-medium transition-colors rounded-md",
      isMobile ? "px-3 py-2 mb-4" : "px-4 py-2"
    );

    if (isAuthenticated) {
      return (
        <Button variant="secondary" size="small" onClick={handleLogout}>
          Logout
        </Button>
      );
    }

    return (
      <>
        <Link
          to="/login"
          onClick={isMobile ? closeMobileMenu : undefined}
          className={clsx(
            baseButtonClass,
            "bg-blue-600 text-white hover:bg-blue-700",
            { "block text-center": isMobile }
          )}
        >
          Login
        </Link>
        <Link
          to="/register"
          onClick={isMobile ? closeMobileMenu : undefined}
          className={clsx(
            baseButtonClass,
            "border border-gray-300 text-gray-700 hover:bg-gray-50",
            { "block text-center": isMobile }
          )}
        >
          Register
        </Link>
      </>
    );
  };

  return (
    <header className="relative bg-white shadow-sm">
      <div className="container mx-auto px-4">
        <div className="flex h-16 items-center justify-between">
          <Link to="/" className="flex flex-shrink-0 flex-col">
            <h1 className="text-lg font-bold text-gray-800">
              Event Management System
            </h1>
            <p className="text-xs text-gray-500">Made with React & Go ❤️</p>
          </Link>
          <nav className="hidden items-center space-x-8 md:flex">
            {isAuthenticated && (
              <div className="flex items-center gap-6">
                <NavigationLinks />
              </div>
            )}
            <div className="flex items-center space-x-3">
              <AuthButtons />
            </div>
          </nav>
          <div className="md:hidden">
            <Button
              onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
              variant="secondary"
              icon={
                isMobileMenuOpen ? (
                  <X className="h-5 w-5" />
                ) : (
                  <Menu className="h-5 w-5" />
                )
              }
            ></Button>
          </div>
        </div>
        <div
          className={clsx(
            "transition-all duration-300 ease-in-out md:hidden",
            isMobileMenuOpen ? "max-h-96 pb-4 opacity-100" : "max-h-0 opacity-0"
          )}
        >
          <nav className="border-t border-gray-200 pt-4">
            {isAuthenticated && (
              <div className="mb-4 space-y-3">
                <NavigationLinks isMobile />
              </div>
            )}

            <AuthButtons isMobile />
          </nav>
        </div>
      </div>
    </header>
  );
}

This updated Header component creates a modern, responsive navigation bar with several key features:

The header automatically adapts between desktop and mobile layouts.

🎉 Conclusion

Congratulations! You’ve successfully built a complete event management app using React, TypeScript, Tailwind CSS, and React Router.

Throughout this tutorial, you’ve mastered key React concepts including custom hooks, Context API, form handling, and API integration. You’ve created a fully functional app with user authentication, event CRUD operations, attendee management, and responsive design.

The patterns and techniques you’ve learned here will serve you well in any React project. Consider enhancing your app further with real-time updates, image uploads, or calendar integration.

Keep building and keep learning! 🚀