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 inapp/dashboard/page.tsxdirectly earlier, moving it here centralizes our data operations. We'll updatedashboard/page.tsxto use this action.addTaskAction(formData: FormData):- Takes
formDatadirectly from the form submission. - Extracts
titleand other properties. - Adds a new task to
currentMockTasks. revalidatePath('/dashboard'): This is crucial! It tells Next.js to invalidate the cache for the/dashboardroute. The next time/dashboardis accessed (even through client-side navigation), theDashboardPageServer Component will re-render and re-fetch the latest data.
- Takes
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
useStatefor the maintasksarray. The list now directly rendersinitialTasksfrom props. - The
addTask,toggleTaskCompletion, anddeleteTasklogic now directly call the corresponding Server Actions (addTaskAction,toggleTaskCompletionAction,deleteTaskAction). - The
addTaskfunction now usesevent.preventDefault()and constructsFormDatato pass to the Server Action. - Crucially: The
inputfor the task title now has aname="title"attribute. Thisnameattribute is howFormDataknows which value to extract. - A
SubmitButtoncomponent was created to demonstrateuseFormStatus, disabling the button during form submission. Remember thatuseFormStatuscan 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:
- Ensure your development server is running (
npm run dev). You might need to restart it if you added theactions/tasks.tsfile for the first time or if the mock tasks seemed to not update. - Navigate to
http://localhost:3000/dashboard. - 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.
- Observe the "Add Task" button change to "Adding Task..." (thanks to
- 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.
- 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.