Filtered Task View

Introduction: Why Add a Filtered View?

Welcome to your first lesson in the course Filters, State Sync & Production Polish.

So far, your app allows users to create, view, and edit tasks. But as the list of tasks grows, it becomes harder to focus on what matters most — for example, unfinished tasks or those already completed.

In this lesson, you’ll make your app smarter and easier to navigate by adding a Filtered Task View.

This new feature lets users:

  • Click a Filters tab in the sidebar.
  • Switch between Incomplete and Completed views using buttons.
  • Automatically load and display tasks that match the selected filter.
  • See the active filter highlighted in the UI.

You’ll also learn how to connect the filter state to the URL (e.g. /tasks/filter?completed=false), so users can share or bookmark filtered pages.

What You’ll Build in This Unit

When you finish this unit, your app will support a complete filtered workflow.

What the user can do:

  • Navigate to the new “Filters” page via the sidebar (/tasks/filter).
  • See a filter bar with two buttons: Incomplete and Completed.
  • Click a button to switch filters — for example, from
    /tasks/filter?completed=false/tasks/filter?completed=true.
  • Watch the task list update automatically based on the selected filter.
  • See proper loading and error states if the API call takes time or fails.

What you’ll learn:

  • How to use URL search parameters (?completed=true|false) to manage filter state.
  • How to dynamically fetch filtered data using SWR and your existing API client.
  • How to visually indicate the active filter using button variants.
  • How to extend your sidebar navigation in layout.tsx with new routes.

Learning environment note: Some course workspaces may map import ... from 'swr' to a simplified local SWR shim for predictable exercises. The patterns you learn still match SWR’s core cache-key and mutate() ideas, but real production apps should use the official swr package and verify behavior around stale data, revalidation, and fetcher errors.

Adding the Filter Tab to the Sidebar

Let’s start by adding a new “Filters” link to your app’s main navigation so users can easily access the filtered task view.

Open src/app/(dashboard)/layout.tsx and update the navItems array:

const navItems = [
  { href: '/', label: 'Dashboard' },
  { href: '/tasks', label: 'Tasks' },
  { href: '/tasks/filter', label: 'Filters' }, 
];

The rest of the layout logic already handles highlighting active routes:

const pathname = usePathname();
const isActive = (href: string) =>
  href === '/' ? pathname === '/' : pathname.startsWith(href);

This means when you’re visiting any URL under /tasks/filter, the “Filters” link will automatically be styled as active.

How this works visually:

The sidebar navigation uses Tailwind CSS to apply different background and text colors to the active route.
As you switch between Dashboard, Tasks, and Filters, users always know which section they’re in.

Now, let’s make the actual filter UI.

Using URL Search Parameters for Filtering

The filter feature depends on URL search parameters — the ?completed=true|false part of the URL.

Example URLs:

  • /tasks/filter?completed=false → show only incomplete tasks
  • /tasks/filter?completed=true → show only completed tasks

This approach has several advantages:

  • The filter state is stored in the URL — users can bookmark or share it.
  • When the user reloads the page, the selected filter is preserved.
  • Your app doesn’t need to store filter state separately in React state — it’s derived directly from the URL.

You’ll use two hooks from Next.js for this:

  • useRouter() — to change the URL programmatically.
  • useSearchParams() — to read the current value of completed.

Building the Filter Bar UI

Now, let’s create a TaskFilters component that shows the Incomplete and Completed buttons.

File: src/components/tasks/TaskFilters.tsx

'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/Button';

export function TaskFilters() {
  const router = useRouter();
  const params = useSearchParams();
  const completed = params.get('completed') || 'false';

  return (
    <div className="inline-flex rounded-md border bg-white p-1">
      <Button
        variant={completed === 'false' ? 'primary' : 'secondary'}
        onClick={() => router.push('/tasks/filter?completed=false')}
        className="rounded-r-none"
      >
        Incomplete
      </Button>
      <Button
        variant={completed === 'true' ? 'primary' : 'secondary'}
        onClick={() => router.push('/tasks/filter?completed=true')}
        className="rounded-l-none"
      >
        Completed
      </Button>
    </div>
  );
}

Detailed Breakdown

useRouter()
Used to navigate programmatically. When a user clicks a button, we use router.push() to change the URL.

useSearchParams()
Reads the current query string from the URL.
For example, on /tasks/filter?completed=true,
params.get('completed') will return 'true'.

Dynamic Button Styles
The active button uses the primary variant, while the inactive one uses secondary.
This helps users visually see which filter is active.

Rounded Button Styling
The buttons are joined together visually with rounded-l-none and rounded-r-none, forming a two-part toggle bar.

Behavior on Click
Clicking a button updates the URL. Because the filtered task list depends on the URL, this automatically triggers a re-fetch in your SWR logic.

Fetching and Displaying Filtered Tasks

Now let’s build the main filtered page.

File: src/app/(dashboard)/tasks/filter/page.tsx

'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { api } from '@/lib/apiClient';
import { TaskRow } from '@/components/tasks/TaskRow';
import { TaskFilters } from '@/components/tasks/TaskFilters';

export default function FilterPage() {
  const params = useSearchParams();
  const completed = params.get('completed') || 'false';
  const { data, error, isLoading } = useSWR(
    `/api/tasks/filter?completed=${completed}`,
    api.get<any[]>
  );
  const tasks = Array.isArray(data) ? data : [];

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Filtered Tasks</h1>
        <TaskFilters />
      </div>

      {isLoading && <div className="text-gray-500">Loading filtered tasks…</div>}
      {error && <div className="text-red-600">Failed to load tasks.</div>}

      <div className="divide-y rounded-lg border bg-white">
        {tasks.map((t) => (
          <TaskRow
            key={t.id}
            task={t}
            revalidateKeys={[`/api/tasks/filter?completed=${completed}`, '/api/tasks']}
          />
        ))}
        {tasks.length === 0 && (
          <div className="p-4 text-gray-500">No tasks match this filter.</div>
        )}
      </div>
    </div>
  );
}

How It Works

Reading the URL Filter
The useSearchParams() hook extracts the completed value from the query string.

Dynamic SWR Key
The key passed to useSWR() includes the filter: /api/tasks/filter?completed=${completed}

Every time the completed value changes, SWR automatically re-fetches the data from your backend.

Using the API Client
api.get() performs the actual fetch request.
You don’t need to write separate logic — your reusable API client handles it.

Displaying the List
The results are mapped into TaskRow components, reusing your existing UI for individual tasks.

Error and Loading States

SWR only fills the error value when the fetcher throws. If your apiClient returns an error envelope such as { error, meta }, wrap the fetcher so the envelope becomes a thrown error:

const fetchTasks = async (url: string) => { const res = await api.get<any[]>(url); if (res && typeof res === 'object' && 'error' in res) throw new Error('Failed to load tasks'); return res; };
  • While fetching → show “Loading filtered tasks…”
  • If an error occurs → show a red error message
  • If the list is empty → show “No tasks match this filter.”

- **While fetching** → show “Loading filtered tasks…”  
- **If an error occurs** → show a red error message  
- **If the list is empty** → show “No tasks match this filter.”

**Revalidation**  
The `revalidateKeys` prop is being wired here so the call sites are ready. The actual cross-view cache synchronization behavior is implemented in Unit 2, so do not expect filtered views to fully update across filters yet.

**How Everything Connects**

Once implemented:

- The sidebar gains a “Filters” tab that points to `/tasks/filter`.  
- The **TaskFilters** component controls the active filter and updates the URL.  
- The **FilterPage** dynamically reads from the URL and fetches tasks accordingly.  
- **SWR** handles caching, so switching between filters feels instant.  
- The app shows proper loading, empty, and error states at every step.  

Users can also share or bookmark URLs like `/tasks/filter?completed=true`, and your app will automatically show the right view when opened again.


###### Summary

In this lesson, you learned how to:

- Add a **Filters** route to your sidebar navigation.  
- Use **URL search parameters** to manage and persist filter state.  
- Build a **TaskFilters** component with toggle-style buttons.  
- Dynamically fetch filtered data using **SWR** and your `apiClient`.  
- Display clean **loading**, **error**, and **empty** states for a polished UX.  

This pattern — connecting UI state to the **URL** — is a best practice in production apps.  
It improves usability, makes your app more shareable, and ensures state consistency across navigation.

In the next unit, you’ll extend this further by synchronizing caches between filtered and unfiltered lists, ensuring that all parts of your app always show the most up-to-date task information.



### Summary

This lesson teaches you how to add filtering to a task list in a Next.js app. You learn to use URL search parameters to control which tasks are shown, build a simple filter UI, and fetch and display only the tasks that match the selected filter.

## Task 1 (coding): Exploring the New Filtered Task View  

##### Task Description

**Hey Productivity Prodigy! 🧭**  

In this observation task, you’ll explore your brand-new **Filtered Task View** — a feature designed to make your growing task list easier to manage and navigate.  

Your app now lets users filter between **Incomplete** and **Completed** tasks, using clean navigation and state that syncs directly with the URL.  
You’ll see how UI state, API logic, and navigation all come together to create a seamless and dynamic experience.  

Here’s what to do:  
1. In the **UI preview**, look at the sidebar — notice a new **“Filters”** tab alongside **Dashboard** and **Tasks**.  
2. Click **Filters** to visit `/tasks/filter?completed=false`. You’ll see only **incomplete** tasks.  
3. Use the toggle bar at the top-right — click **Completed** to switch the URL to `/tasks/filter?completed=true`.  
4. Watch how the task list updates automatically, showing only tasks marked complete.  
5. Refresh the page — the selected filter persists because the filter state is stored in the **URL**.  
6. Try navigating between tabs — each section updates instantly and stays in sync, without losing its place.  

> **Note:** Filtered views do not auto-update yet.
In this version of the app, when you mark a task complete/incomplete and then switch between the **Incomplete** and **Completed** filters, the lists will not update automatically.
This is expected — we haven’t added cross-view cache synchronization yet.
In the next unit, you’ll enhance the `TaskRow` component so that all task lists update instantly across the entire app using SWR’s global cache.
Focus on what changes in the URL and which tasks appear for each filter.

##### Project Files

`open_src/app/(dashboard)/layout.tsx`
```tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { ToasterProvider } from '@/components/ui/Toast';

const navItems = [
  { href: '/', label: 'Dashboard' },
  { href: '/tasks', label: 'Tasks' },
  { href: '/tasks/filter', label: 'Filters' },
];

export default function DashboardLayout({ children }: { children: ReactNode }) {
  const pathname = usePathname();
  const isActive = (href: string) => (href === '/' ? pathname === '/' : pathname.startsWith(href));

  return (
    <ToasterProvider>
      <div className="min-h-screen grid grid-cols-1 md:grid-cols-[240px_1fr]">
        <aside className="hidden md:block border-r bg-white">
          <div className="p-4 text-xl font-semibold">TaskManager</div>
          <nav className="px-2 space-y-1">
            {navItems.map((item) => (
              <Link
                key={item.href}
                href={item.href}
                className={`block rounded-md px-3 py-2 text-sm font-medium hover:bg-gray-100 ${
                  isActive(item.href) ? 'bg-gray-100 text-gray-900' : 'text-gray-700'
                }`}
              >
                {item.label}
              </Link>
            ))}
          </nav>
        </aside>
        <div className="flex flex-col">
          <header className="sticky top-0 z-10 border-b bg-white/80 backdrop-blur h-14 flex items-center px-4">
            <div className="font-semibold">TaskManager</div>
          </header>
          <main className="p-4 md:p-6 lg:p-8">
            <div className="mx-auto max-w-5xl">{children}</div>
          </main>
        </div>
      </div>
    </ToasterProvider>
  );
}

open_src/components/tasks/TaskFilters.tsx

'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/Button';

export function TaskFilters() {
  const router = useRouter();
  const params = useSearchParams();
  const completed = params.get('completed') || 'false';

  return (
    <div className="inline-flex rounded-md border bg-white p-1">
      <Button
        variant={completed === 'false' ? 'primary' : 'secondary'}
        onClick={() => router.push('/tasks/filter?completed=false')}
        className="rounded-r-none"
      >
        Incomplete
      </Button>
      <Button
        variant={completed === 'true' ? 'primary' : 'secondary'}
        onClick={() => router.push('/tasks/filter?completed=true')}
        className="rounded-l-none"
      >
        Completed
      </Button>
    </div>
  );
}

open_src/app/(dashboard)/tasks/filter/page.tsx

'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { api } from '@/lib/apiClient';
import { TaskRow } from '@/components/tasks/TaskRow';
import { TaskFilters } from '@/components/tasks/TaskFilters';

export default function FilterPage() {
  const params = useSearchParams();
  const completed = params.get('completed') || 'false';
  const { data, error, isLoading } = useSWR(`/api/tasks/filter?completed=${completed}`, api.get<any[]>);
  const tasks = Array.isArray(data) ? data : [];

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Filtered Tasks</h1>
        <TaskFilters />
      </div>

      {isLoading && <div className="text-gray-500">Loading filtered tasks…</div>}
      {error && <div className="text-red-600">Failed to load tasks.</div>}

      <div className="divide-y rounded-lg border bg-white">
        {tasks.map((t) => (
          <TaskRow
            key={t.id}
            task={t}
            revalidateKeys={[`/api/tasks/filter?completed=${completed}`, '/api/tasks']}
          />
        ))}
        {tasks.length === 0 && (
          <div className="p-4 text-gray-500">No tasks match this filter.</div>
        )}
      </div>
    </div>
  );
}
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