State Management: Filtering and Sorting
- 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.
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.
- 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