Skip to content

App Integration: Implementing Server Actions for Task Management

Now, let's refactor our TaskList component to use Server Actions for adding, toggling completion, and deleting tasks. This will remove the need for client-side state mutations to be the source of truth, moving them to the server.

1. Create a Server Actions File (actions/tasks.ts)

First, create a new directory named actions at the root of your project (or inside src/ if you're using it). Inside it, create tasks.ts.

my-task-app/
├── app/
│   ├── ...
├── components/
│   ├── ...
├── actions/                  <-- NEW DIRECTORY
│   └── tasks.ts              <-- NEW FILE
└── ...

actions/tasks.ts

// actions/tasks.ts
// This file contains Server Actions for our Task Management App.
// The 'use server' directive makes all exported functions Server Actions.
'use server';

import { revalidatePath } from 'next/cache'; // Import revalidatePath for cache invalidation
import { redirect } from 'next/navigation'; // For redirecting after certain actions (optional for now)

// Define a type for our task, consistent with the mock API
interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  priority: 'Low' | 'Medium' | 'High';
  dueDate: string;
}

// In a real application, these tasks would be stored in a database (e.g., Firestore).
// For now, we'll use a simple in-memory array to simulate database operations.
// This array will reset when the server restarts.
let currentMockTasks: Task[] = [
  {
    id: 'task-1',
    title: 'Complete Module 6: Server Actions',
    description: 'Implement create, update, delete using Server Actions.',
    completed: false,
    priority: 'High',
    dueDate: '2025-06-15',
  },
  {
    id: 'task-2',
    title: 'Refactor TaskList component',
    description: 'Update TaskList to use Server Actions for mutations.',
    completed: false,
    priority: 'High',
    dueDate: '2025-06-12',
  },
  {
    id: 'task-3',
    title: 'Set up Next.js authentication',
    description: 'Prepare for integrating Firebase Authentication in Module 8.',
    completed: false,
    priority: 'Medium',
    dueDate: '2025-06-20',
  },
];

// Server Action to get all tasks (used by page.tsx)
export async function getTasks(): Promise<Task[]> {
  // Simulate database fetch delay
  await new Promise(resolve => setTimeout(resolve, 500));
  return currentMockTasks;
}

// Server Action to add a new task
export async function addTaskAction(formData: FormData) {
  // Simulate network delay
  await new Promise(resolve => setTimeout(resolve, 1000));

  const title = formData.get('title') as string;

  if (!title || title.trim() === '') {
    console.error('Task title cannot be empty.');
    // In a real app, you'd handle errors gracefully (e.g., throw an error that error.tsx catches)
    return { error: 'Task title is required.' };
  }

  const newTask: Task = {
    id: crypto.randomUUID(),
    title: title.trim(),
    description: formData.get('description') as string || 'No description provided.',
    completed: false,
    priority: (formData.get('priority') as 'Low' | 'Medium' | 'High') || 'Medium',
    dueDate: formData.get('dueDate') as string || new Date().toISOString().split('T')[0],
  };

  currentMockTasks.push(newTask); // Add to our in-memory "database"

  // Revalidate the /dashboard path to ensure the UI updates with the new task
  // This tells Next.js to clear its cache for this path and refetch data on next request.
  revalidatePath('/dashboard');
  console.log('Task added:', newTask.title);
}

// Server Action to toggle task completion
export async function toggleTaskCompletionAction(taskId: string, currentCompletedStatus: boolean) {
  await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay

  const taskIndex = currentMockTasks.findIndex(task => task.id === taskId);
  if (taskIndex > -1) {
    currentMockTasks[taskIndex].completed = !currentCompletedStatus;
    console.log(`Task ${taskId} completion toggled to ${currentMockTasks[taskIndex].completed}`);
    revalidatePath('/dashboard'); // Revalidate to show updated status
  } else {
    console.error(`Task with ID ${taskId} not found.`);
  }
}

// Server Action to delete a task
export async function deleteTaskAction(taskId: string) {
  await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay

  const initialLength = currentMockTasks.length;
  currentMockTasks = currentMockTasks.filter(task => task.id !== taskId);

  if (currentMockTasks.length < initialLength) {
    console.log(`Task ${taskId} deleted.`);
    revalidatePath('/dashboard'); // Revalidate to remove deleted task from UI
  } else {
    console.error(`Task with ID ${taskId} not found for deletion.`);
  }
}

Explanation of actions/tasks.ts:

  • 'use server': Declares all functions in this file as Server Actions.
  • currentMockTasks: A simple in-memory array acting as our "database." Important: This will reset when your development server restarts. In Module 7, we'll replace this with a real database (Firestore).
  • getTasks(): A Server Action to fetch tasks. While we used this in app/dashboard/page.tsx directly earlier, moving it here centralizes our data operations. We'll update dashboard/page.tsx to use this action.
  • addTaskAction(formData: FormData):
    • Takes formData directly from the form submission.
    • Extracts title and other properties.
    • Adds a new task to currentMockTasks.
    • revalidatePath('/dashboard'): This is crucial! It tells Next.js to invalidate the cache for the /dashboard route. The next time /dashboard is accessed (even through client-side navigation), the DashboardPage Server Component will re-render and re-fetch the latest data.
  • toggleTaskCompletionAction(taskId: string, currentCompletedStatus: boolean):
    • Takes the task ID and its current status as arguments.
    • Finds and updates the task in currentMockTasks.
    • revalidatePath('/dashboard'): Revalidates the path to reflect the updated status.
  • deleteTaskAction(taskId: string):
    • Takes the task ID.
    • Filters the task out of currentMockTasks.
    • revalidatePath('/dashboard'): Revalidates to remove the task from the UI.

2. Update app/dashboard/page.tsx to Use getTasks Server Action

We'll update our DashboardPage to call the getTasks Server Action instead of a separate fetch call to our mock API route.

app/dashboard/page.tsx (Update this file)

// app/dashboard/page.tsx
// This is the dashboard page, now a Server Component that fetches tasks
// using the `getTasks` Server Action.
// It will pass fetched data to the TaskList Client Component.

import TaskList from '../../components/TaskList'; // Import TaskList
import { getTasks } from '../../actions/tasks'; // Import the getTasks Server Action
import { Metadata } from 'next';

// Define the type for our fetched tasks (consistent with actions/tasks.ts)
interface FetchedTask {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  priority: 'Low' | 'Medium' | 'High';
  dueDate: string;
}

export const metadata: Metadata = {
  title: 'Dashboard | My TaskFlow',
  description: 'View and manage all your tasks from your personalized dashboard.',
};

export default async function DashboardPage() {
  let tasks: FetchedTask[] = [];
  let error: string | null = null;

  try {
    // Call the Server Action directly to fetch tasks
    tasks = await getTasks();
  } catch (err) {
    error = err instanceof Error ? err.message : 'An unknown error occurred.';
    console.error('Error fetching tasks:', error);
  }

  return (
    <div className="flex flex-col items-center p-4">
      <h2 className="text-3xl font-bold text-gray-800 mb-6">Your Dashboard</h2>
      <p className="text-lg text-gray-600 mb-8">
        Manage your tasks efficiently right from here.
      </p>

      {error ? (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
          <strong className="font-bold">Error!</strong>
          <span className="block sm:inline"> {error}</span>
        </div>
      ) : (
        // Pass the fetched tasks as props to the TaskList Client Component
        <TaskList initialTasks={tasks} />
      )}
    </div>
  );
}

3. Update components/TaskList.tsx to Use Server Actions for Mutations

Now, we will refactor the TaskList component to invoke our new Server Actions for adding, toggling completion, and deleting tasks. We will also integrate useFormStatus to show a pending state during form submission.

components/TaskList.tsx (Update this file)

// components/TaskList.tsx
// This Client Component now uses Server Actions for task mutations (add, toggle, delete).
// It also integrates `useFormStatus` for pending states.
'use client'; // This component remains a Client Component due to state and interactivity.

import { useState, useEffect, FormEvent } from 'react';
import { useFormStatus } from 'react-dom'; // Import useFormStatus
import { addTaskAction, toggleTaskCompletionAction, deleteTaskAction } from '@/app/actions/tasks'; // Import Server Actions

interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  priority: 'Low' | 'Medium' | 'High';
  dueDate: string;
}

interface TaskListProps {
  initialTasks: Task[];
}

// Helper component for the form's submit button
// This component must be a Client Component because it uses useFormStatus.
function SubmitButton() {
  const { pending } = useFormStatus(); // Get the pending status of the form

  return (
    <button
      type="submit"
      className="px-6 py-3 bg-blue-600 text-white rounded-md shadow-md hover:bg-blue-700 transition duration-300 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed"
      disabled={pending} // Disable button while pending
    >
      {pending ? 'Adding Task...' : 'Add Task'}
    </button>
  );
}

export default function TaskList({ initialTasks }: TaskListProps) {
  // We still use useState for the input field value
  const [newTaskTitle, setNewTaskTitle] = useState<string>('');

  // We are no longer managing the `tasks` array locally with `useState` for the main list,
  // as the source of truth will now be the server and data revalidation.
  // The `initialTasks` prop ensures the component renders with the latest data from the server.
  // We will re-render the list naturally when `revalidatePath` in Server Action triggers a refresh.

  // The state for editing/deleting will be handled by re-rendering of the parent.
  // The `initialTasks` prop becomes the source of truth for display.

  // The form's `action` prop will directly invoke the Server Action.
  // We need to clear the input field after successful submission.
  const handleAddTaskFormSubmit = async (event: FormEvent) => {
    event.preventDefault(); // Prevent default browser form submission
    const formData = new FormData(event.currentTarget as HTMLFormElement); // Create FormData from the form

    // Here you can add client-side validation before sending to server
    if (newTaskTitle.trim() === '') {
      console.log('Client-side: Task title cannot be empty.');
      // Display user-friendly error (e.g., using a custom modal/toast)
      return;
    }

    // Call the Server Action
    await addTaskAction(formData);
    setNewTaskTitle(''); // Clear the input field after successful submission
  };

  // Event handler for toggling task completion, directly calls Server Action
  const handleToggleTaskCompletion = async (taskId: string, currentStatus: boolean) => {
    await toggleTaskCompletionAction(taskId, currentStatus);
    // UI will revalidate automatically due to revalidatePath in Server Action
  };

  // Event handler for deleting a task, directly calls Server Action
  const handleDeleteTask = async (taskId: string) => {
    // Optional: Add a confirmation dialog here before deleting
    // In a real app, use a custom modal for confirmation, not alert
    if (confirm("Are you sure you want to delete this task?")) {
        await deleteTaskAction(taskId);
        // UI will revalidate automatically due to revalidatePath in Server Action
    }
  };

  return (
    <div className="w-full max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-xl border border-gray-200">
      <h3 className="text-3xl font-bold text-gray-800 mb-6">Your Tasks</h3>

      {/* Task Input Form - now directly uses a Server Action */}
      <form onSubmit={handleAddTaskFormSubmit} className="flex flex-col md:flex-row gap-4 mb-8">
        <input
          type="text"
          name="title" // Crucial: name attribute for FormData
          placeholder="Add a new task..."
          value={newTaskTitle}
          onChange={(e) => setNewTaskTitle(e.target.value)}
          className="flex-grow p-3 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 outline-none"
        />
        {/* We can add hidden inputs for other properties or prompt user */}
        {/* <input type="hidden" name="description" value="Default description" /> */}
        {/* <input type="hidden" name="priority" value="Medium" /> */}
        {/* <input type="date" name="dueDate" /> */}

        {/* Use the SubmitButton component */}
        <SubmitButton />
      </form>

      {/* Task List - now uses initialTasks from props */}
      {initialTasks.length === 0 ? (
        <p className="text-center text-gray-500 italic">No tasks yet. Start adding some!</p>
      ) : (
        <ul className="space-y-4">
          {initialTasks.map((task) => ( // Render initialTasks directly
            <li
              key={task.id}
              className="flex items-center justify-between p-4 bg-gray-50 border border-gray-200 rounded-md shadow-sm transition duration-200 hover:bg-gray-100"
            >
              <div className="flex items-start flex-grow">
                <input
                  type="checkbox"
                  checked={task.completed}
                  onChange={() => handleToggleTaskCompletion(task.id, task.completed)}
                  className="form-checkbox h-5 w-5 text-blue-600 rounded mr-3 mt-1 cursor-pointer flex-shrink-0"
                />
                <div className="flex flex-col flex-grow">
                  <span
                    className={`text-lg font-medium ${
                      task.completed ? 'line-through text-gray-500' : 'text-gray-900'
                    }`}
                  >
                    {task.title}
                  </span>
                  <p className="text-sm text-gray-500 mt-1">
                    Priority: <span className="font-semibold">{task.priority}</span> | Due: <span className="font-semibold">{task.dueDate}</span>
                  </p>
                  <p className="text-sm text-gray-600 mt-1 italic">{task.description}</p>
                </div>
              </div>
              <button
                onClick={() => handleDeleteTask(task.id)}
                className="ml-4 px-3 py-1 bg-red-500 text-white rounded-md text-sm shadow-sm hover:bg-red-600 transition duration-300 flex-shrink-0"
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Key Changes in TaskList.tsx:

  • Removed useState for the main tasks array. The list now directly renders initialTasks from props.
  • The addTask, toggleTaskCompletion, and deleteTask logic now directly call the corresponding Server Actions (addTaskAction, toggleTaskCompletionAction, deleteTaskAction).
  • The addTask function now uses event.preventDefault() and constructs FormData to pass to the Server Action.
  • Crucially: The input for the task title now has a name="title" attribute. This name attribute is how FormData knows which value to extract.
  • A SubmitButton component was created to demonstrate useFormStatus, disabling the button during form submission. Remember that useFormStatus can only be used in a Client Component.
  • The confirm() alert is still present for deletion, but a reminder is added to replace it with a custom modal in a real app.

To test Server Actions:

  1. Ensure your development server is running (npm run dev). You might need to restart it if you added the actions/tasks.ts file for the first time or if the mock tasks seemed to not update.
  2. Navigate to http://localhost:3000/dashboard.
  3. Add a new task: Type a title in the input field and click "Add Task" or press Enter.
    • Observe the "Add Task" button change to "Adding Task..." (thanks to useFormStatus).
    • After a brief delay, the new task should appear in the list without a full page refresh. This is Next.js revalidating the data and re-rendering the relevant parts of the UI.
  4. Mark a task complete/incomplete: Click the checkbox next to an existing task.
    • The task should visually update (strikethrough/un-strikethrough) after a brief delay.
  5. Delete a task: Click the "Delete" button next to a task.
    • Confirm the deletion (for now).
    • The task should disappear from the list after a brief delay.

You've now successfully implemented data mutations using Next.js Server Actions! This is a powerful and efficient way to interact with your backend data, reducing client-side code and leveraging Next.js's integrated caching and revalidation mechanisms. In the next module, we will replace our in-memory mock database with a real-time, persistent database: Firestore.