Learn Build a Real SaaS with Claude Code State Management: Filtering and Sorting

State Management: Filtering and Sorting

Intermediate 🕐 20 min Lesson 10 of 25
What you'll learn
  • Identify that filter and sort state can live in the URL instead of a client store
  • Trace how a single getReviewsForBusiness query function serves both the API route and the dashboard page
  • Articulate why a state management library is unnecessary for this app's actual scope
  • Recognize the narrow, real need for client state in the response form, in contrast to the filter UI
  • Use the URL-as-state pattern to get shareable, bookmarkable filtered views for free
  • Distinguish form-level pending/error state (useActionState) from app-wide shared state (Redux/Zustand)

The Question Worth Asking First

Before writing a single filter control, it is worth asking: does this dashboard need Redux, Zustand, Jotai, or any client-side state library at all? For this app, the honest answer is no -- and that is not a corner cut for the tutorial, it is the correct architectural call given the app's actual scope. This lesson explains why, using the filter/sort logic already built in lessons 8-9.

Where the State Actually Lives

Look at how app/dashboard/page.tsx reads its filters:

export default async function DashboardPage({
  searchParams,
}: {
  searchParams: Promise<{ status?: string; source?: string; sort?: string }>;
}) {
  const params = await searchParams;
  const status = STATUS_OPTIONS.includes(params.status as ReviewStatus)
    ? (params.status as ReviewStatus)
    : undefined;
  // ...same pattern for source and sort
}

The "currently selected filter" is not stored in a useState hook, a context provider, or a global store. It is stored in the URL's query string. Clicking a filter link navigates to a new URL; Next.js re-runs the Server Component with the new searchParams; getReviewsForBusiness runs a fresh, already-filtered Drizzle query. There is no client-side array to filter in memory, because the filtering happens in SQL before the data ever reaches the browser:

// db/queries.ts
export async function getReviewsForBusiness(
  businessId: string,
  filters: { status?: ReviewStatus; source?: ReviewSource; sort?: ReviewSort } = {},
): Promise {
  const conditions = [eq(reviews.businessId, businessId)];
  if (filters.status) conditions.push(eq(reviews.status, filters.status));
  if (filters.source) conditions.push(eq(reviews.source, filters.source));

  const orderBy = {
    newest: desc(reviews.postedAt),
    oldest: asc(reviews.postedAt),
    rating_asc: asc(reviews.rating),
    rating_desc: desc(reviews.rating),
  }[filters.sort ?? "newest"];

  return db.select().from(reviews).where(and(...conditions)).orderBy(orderBy);
}

This is the same function the API route from lesson 8 calls, and the same one the dashboard page calls -- one query function, two callers, zero duplicated filter logic.

Why This Is the Right Call Here, Not a Shortcut

State management libraries solve a specific problem: coordinating state that many disconnected components need to read and write, often across deep component trees, often needing to survive client-side navigation without a server round trip. None of that is true here. There is exactly one screen that filters reviews, the filter values fit entirely in three URL query params, and a full page navigation on every filter change is not a performance problem -- it is a handful of indexed Postgres reads.

Reaching for Zustand or Redux on a dashboard like this would add a dependency, a provider component, and a second source of truth that has to be kept in sync with the URL anyway (for shareable/bookmarkable filtered views, you would want the URL to reflect state regardless). The URL-as-state pattern gets that synchronization for free.

The standing lesson here: don't reach for a state library because a tutorial said so, or because it is the default starting point on a new project. Reach for one when you have state that genuinely needs to be shared across components that don't have a parent-child relationship, or that needs to persist across a client-side route change without a server fetch. A single filtered list page is neither.

Where Client State Does Show Up

This is not a claim that the whole app is server-only. The sort <select> is inside a plain <form> with hidden inputs carrying the current status/source forward, so changing the dropdown and submitting still produces one clean URL with all three params. That is a few lines of native HTML form behavior, not React state. The one place this app genuinely needs "use client" and a hook is the response form in lesson 11, where pending/success/error states have to update without a full page reload -- a real, scoped need for client state, handled with React's built-in useActionState rather than an external library.

Need This app's answer When you'd reach further
Filter/sort a list
URL searchParams + server query
Client state if filtering must avoid any navigation, e.g. a live-typing search-as-you-type
Form pending/error state
useActionState (built into React)
A form library if you have many complex, multi-step forms
State shared across unrelated components
Not needed in this app
Zustand/Redux/Jotai once prop-drilling or context nesting gets painful

Lesson 11 wires the response form's client interactivity into the server-rendered detail page from lesson 9, completing the full ingest-to-respond loop.

Key takeaways
  • Filter and sort state stored in the URL's query string needs no client store, and is shareable/bookmarkable by default
  • One query function (getReviewsForBusiness) backing both the REST API and the page avoids duplicating filter logic in two places
  • A full Server Component re-render on filter change is not a performance problem when the underlying query is a few indexed Postgres reads
  • State libraries solve cross-component sharing and client-side persistence problems -- a single filtered list page has neither
  • useActionState is React's built-in answer for form pending/success/error state and does not require an external library
  • Choosing not to add a dependency is itself an architectural decision worth making deliberately, not a default to fight against