Lesson 13 +10 XP

Project: Server Actions Dashboard & Analytics

Project: Server Actions Dashboard & Analytics

In this project, you will build an interactive Task & Analytics Dashboard powered by Server Actions, useActionState, Optimistic UI, and Skeleton Loading boundaries.

Project Requirements

  • Instant optimistic updates when creating tasks.
  • Background mutation using Server Actions ('use server').
  • Skeleton loader in loading.tsx using React Suspense.

1. Backend Server Actions (app/actions/tasks.ts)

'use server';

import { revalidatePath } from 'next/cache';

export type Task = { id: string; title: string; completed: boolean };

let mockTasks: Task[] = [
  { id: '1', title: 'Review Pull Request', completed: false },
  { id: '2', title: 'Deploy Next.js 15 App', completed: true },
];

export async function getTasks(): Promise<Task[]> {
  // Simulate network latency
  await new Promise((r) => setTimeout(r, 800));
  return mockTasks;
}

export async function addTaskAction(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;

  if (!title || title.length < 3) {
    return { error: 'Task title must be at least 3 characters long' };
  }

  const newTask: Task = { id: Date.now().toString(), title, completed: false };
  mockTasks.push(newTask);

  revalidatePath('/dashboard');
  return { success: true };
}

2. Client Task Form with Optimistic UI (components/TaskForm.tsx)

'use client';

import { useActionState, useOptimistic, useRef } from 'react';
import { addTaskAction, Task } from '@/app/actions/tasks';

export default function TaskForm({ initialTasks }: { initialTasks: Task[] }) {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, formAction] = useActionState(addTaskAction, null);

  const [optimisticTasks, addOptimisticTask] = useOptimistic(
    initialTasks,
    (state: Task[], title: string) => [
      ...state,
      { id: 'temp-' + Date.now(), title, completed: false },
    ]
  );

  async function handleSubmit(formData: FormData) {
    const title = formData.get('title') as string;
    if (title) {
      addOptimisticTask(title);
      formRef.current?.reset();
    }
    await formAction(formData);
  }

  return (
    <div className="space-y-4">
      <form ref={formRef} action={handleSubmit} className="flex gap-2">
        <input name="title" placeholder="New Task..." className="border p-2 flex-1 rounded" required />
        <button type="submit" className="bg-indigo-600 text-white px-4 py-2 rounded">
          Add Task
        </button>
      </form>

      {state?.error && <p className="text-red-500 text-sm">{state.error}</p>}

      <ul className="space-y-2">
        {optimisticTasks.map((task) => (
          <li key={task.id} className="p-3 border rounded bg-slate-50 flex items-center justify-between">
            <span>{task.title}</span>
            {task.id.startsWith('temp-') && (
              <span className="text-xs text-amber-600 animate-pulse">Syncing...</span>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

3. Dashboard Skeleton Loader (app/dashboard/loading.tsx)

export default function LoadingDashboard() {
  return (
    <div className="p-8 space-y-4 animate-pulse">
      <div className="h-8 w-64 bg-slate-200 rounded"></div>
      <div className="h-12 w-full bg-slate-200 rounded"></div>
      <div className="h-32 w-full bg-slate-200 rounded"></div>
    </div>
  );
}

TL;DR

  • Combine useOptimistic with useActionState for high-performance form UIs.
  • Skeleton loaders in loading.tsx provide instant visual feedback while RSC data fetches.
  • Revalidate paths dynamically in Server Actions using revalidatePath().