Optimistic Status Toggle

Introduction: Making Task Status Updates Feel Instant

Welcome back! So far, you’ve built a full-featured Task Detail Page — you can view, edit, and delete tasks safely with a confirmation modal.

Now it’s time to make your app feel faster and more responsive by adding an optimistic status toggle.

An optimistic UI immediately updates the interface before waiting for the server to confirm a change.
For example, when a user clicks “Mark Complete,” the task instantly looks complete — even though the network request is still in progress.
If the server later fails, the UI rolls back to its previous state, and the user sees an error toast.

This technique makes your app feel instantaneous, fluid, and professional.

What You’ll Implement in This Unit

In this final unit, you’ll focus on interactivity and responsiveness.
After completing this lesson, users will be able to:

  • Click Mark Complete or Mark Incomplete on the Task Detail Page.
  • See the UI change immediately without any delay.
  • Have the updated status reflected across the Dashboard and Tasks List pages automatically.
  • Receive success or error feedback via toasts.
  • See the UI revert correctly if the API call fails.

This lesson brings together multiple skills you’ve learned:

  • Reusing API logic (api.patch)
  • Managing cache consistency with SWR’s mutate()
  • Delivering instant UI feedback with optimistic updates and toasts

Understanding Optimistic Updates

Normally, when you send a request (like marking a task complete), you:

  1. Click a button.
  2. Wait for the API call to finish.
  3. The UI updates only after a successful response.

This can make the app feel slow, especially on poor connections.

An optimistic update flips that flow:

  1. You click.
  2. The UI updates immediately (we assume success).
  3. The API call runs in the background.
  4. If the server confirms, we keep the new state.
  5. If it fails, we roll back and show an error message.

This approach balances user experience with reliability.

Optimistic Update Timeline

  1. Current cache: SWR starts with the task as it exists now.
  2. Optimistic cache write: mutate(key, optimistic, false) updates the UI immediately without refetching.
  3. Background request: api.patch() sends the real status change to the server.
  4. Success path: replace the optimistic value with the server response, then revalidate.
  5. Failure path: show an error toast and revalidate both caches to roll back to confirmed server data.

Adding the Toggle Button

In your Task Detail Page (src/app/(dashboard)/tasks/[id]/page.tsx), the button to trigger the toggle is already defined:

<Button variant="secondary" onClick={toggleCompleted}>
  Mark {task.completed ? 'Incomplete' : 'Complete'}
</Button>

The button text changes dynamically depending on the current completion status.

Clicking it calls the toggleCompleted function, which handles the optimistic update logic.

Step-by-Step: Implementing the Optimistic Toggle

Here’s the full toggleCompleted function:

const toggleCompleted = async () => {
  if (!task) return;
  const optimistic = { ...task, completed: !task.completed };
  mutate(key, optimistic, false);
  mutate('/api/tasks', (tasks: any[] | undefined) =>
    Array.isArray(tasks) ? tasks.map((t) => (t.id === task.id ? optimistic : t)) : tasks,
    false
  );

  const res = await api.patch<any>(key, { completed: optimistic.completed });
  if ('error' in res) {
    toast.error('Failed to update status');
    mutate(key);
    mutate('/api/tasks');
  } else {
    toast.success(`Marked ${optimistic.completed ? 'complete' : 'incomplete'}`);
    const updated = res as any;
    mutate(key, updated, false);
    mutate('/api/tasks', (tasks: any[] | undefined) =>
      Array.isArray(tasks) ? tasks.map((t) => (t.id === updated.id ? updated : t)) : tasks,
      false
    );
    mutate(key);
    mutate('/api/tasks');
  }
};

Step 1: Create an Optimistic Version of the Task

const optimistic = { ...task, completed: !task.completed };

We make a copy of the existing task and flip the completed field.

This represents what the task should look like if the update succeeds.

Step 2: Update the SWR Cache Immediately

mutate(key, optimistic, false);

mutate() lets us manually change SWR’s cached data.

  • The first argument is the cache key (here, the detail view /api/tasks/:id).
  • The second argument is the new value (our optimistic task).
  • The third argument false means “don’t refetch from the server yet.”

At this moment, the UI instantly shows the updated status.

We also need to update the cached task list:

mutate('/api/tasks', (tasks: any[] | undefined) =>
  Array.isArray(tasks) ? tasks.map((t) => (t.id === task.id ? optimistic : t)) : tasks,
  false
);

This line updates the cached array of tasks for /api/tasks, ensuring that the Tasks List and Dashboard immediately reflect the same change.

Now, both the detail view and list view look consistent right away.

Step 3: Send the Actual Request

const res = await api.patch<any>(key, { completed: optimistic.completed });

Here, we send the real PATCH request to the backend.
The body only includes the updated completed value.
This runs asynchronously, so the UI remains responsive.

Step 4: Handling API Success or Failure

If something goes wrong:

if ('error' in res) {
  toast.error('Failed to update status');
  mutate(key);
  mutate('/api/tasks');
}

We show a red toast message to inform the user.
We revalidate both /api/tasks and the task’s key to reload the correct data from the backend, effectively rolling back to the original state.

If the request succeeds:

toast.success(`Marked ${optimistic.completed ? 'complete' : 'incomplete'}`);
const updated = res as any;
mutate(key, updated, false);
mutate('/api/tasks', (tasks: any[] | undefined) =>
  Array.isArray(tasks) ? tasks.map((t) => (t.id === updated.id ? updated : t)) : tasks,
  false
);
mutate(key);
mutate('/api/tasks');

A success toast confirms the change.
We update both caches again (key for the single task, /api/tasks for the list) with the data returned by the server.
Finally, we trigger revalidation to ensure the UI is perfectly in sync.

Understanding SWR’s mutate() and Caching Logic

SWR is what makes this optimistic UI possible.
It manages a central cache keyed by URL (like /api/tasks or /api/tasks/:id).

mutate() can do three things:

  1. Instantly modify the cached data (without waiting for an API response).
  2. Revalidate (re-fetch) the data to confirm with the backend.
  3. Synchronize multiple caches that depend on each other.

In your app:

  • mutate(key, optimistic, false) updates the individual task page cache.
  • mutate('/api/tasks', …) ensures the main list and dashboard match.
  • Calling mutate(key) later re-fetches from the server to confirm everything is correct.

This gives you a balance of speed and reliability.

How the UI Responds in Real Time

Let’s walk through what happens from a user’s perspective:

  1. You open /tasks/42 (a task that’s incomplete).
  2. You click “Mark Complete.”
  3. The button instantly changes to “Mark Incomplete.”
  4. The Dashboard and Tasks List also update their counts and task statuses immediately.
  5. After a short moment, the backend confirms the change, and everything stays consistent.
  6. If something fails (e.g., no network), the change rolls back automatically, and you see an error toast.

This workflow makes your app feel instant and reliable, even when network conditions aren’t perfect.

Global Consistency Across the App

Because both your Dashboard and Tasks List use SWR to fetch from /api/tasks, the updates cascade automatically.

In src/app/(dashboard)/page.tsx and src/app/(dashboard)/tasks/page.tsx, you’ll notice:

useEffect(() => {
  mutate('/api/tasks');
}, []);

This ensures that when these pages mount, they fetch the latest version of the tasks list.

For this course, we use explicit mutate('/api/tasks') on mount as a simple synchronization fallback that is easy to see and reason about. In a production app, you may prefer SWR options such as revalidateOnMount or revalidateIfStale to avoid broad manual invalidation when SWR’s built-in revalidation behavior is enough.

Thanks to the shared cache and mutate() calls in your TaskDetailPage, toggling the completion status on one page automatically keeps every other view updated — no manual reloads required.

Handling Edge Cases

Optimistic updates must handle possible failures gracefully.

In your implementation:

  • A failure triggers both rollback (mutate(key), mutate('/api/tasks')) and error toasts.
  • Users always see the correct data after a short refresh.
  • Even if a network glitch happens, data integrity is maintained automatically through SWR’s caching and revalidation.

Summary

In this final unit, you learned how to:

  • Implement optimistic UI updates using SWR’s mutate() API.
  • Keep multiple caches (task detail, list, dashboard) synchronized.
  • Use api.patch() for dynamic updates to task status.
  • Handle both successful updates and server errors smoothly.
  • Deliver instant, responsive UI feedback through toast notifications.

With this, your Task Manager app now:

  • Loads data efficiently,
  • Updates seamlessly,
  • Handles errors gracefully, and
  • Provides a smooth, real-world user experience.

🎉 Congratulations — you’ve completed the journey of building a full-featured Next.js task management app with dynamic data, validation, caching, and a polished UX!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal