Final StretchOrdered learning track

Case Study: Complex Search Page

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 114

Case study production-grade complex search page: URL state, TanStack Query cache, filters, pagination, selection state, stale data, permissions, performance, observability, dan failure modes.

9 min read1664 words
PrevNext
Lesson 114123 lesson track102–123 Final Stretch
#react#case-study#state-management#url-state+2 more

Part 114 — Case Study: Complex Search Page

Complex search page adalah salah satu tempat terbaik untuk menguji pemahaman React state. Di permukaan terlihat sederhana:

input keyword
filter status
sort
pagination
table result

Di produksi, search page cepat berubah menjadi sistem state yang kompleks:

URL harus shareable
filter punya draft dan committed state
query cache harus benar
pagination harus reset saat filter berubah
selection state harus stabil saat data berganti
server response bisa stale
mutation bisa mengubah row yang sedang terlihat
permission memengaruhi action
table besar bisa mahal render
empty/error/loading state harus eksplisit
observability harus cukup untuk debugging

Bagian ini membangun case study dengan gaya architecture handbook. Tujuannya bukan membuat satu library search. Tujuannya membentuk mental model dan blueprint yang bisa diterapkan ke enterprise search page.


1. Scenario

Kita membangun halaman pencarian regulatory cases.

User bisa:

mencari case berdasarkan keyword
filter berdasarkan status, priority, assignee, date range
sort berdasarkan updatedAt, dueDate, priority
paginate hasil
memilih beberapa row
melakukan bulk assign / bulk close
membuka detail modal
menyimpan filter sebagai saved view
membagikan URL ke rekan kerja

Constraint:

URL harus canonical dan shareable
server adalah source of truth untuk result
filter draft boleh lokal
committed search params harus ada di URL
query key harus stabil
pagination reset saat filter utama berubah
bulk selection tidak boleh salah target
action harus permission-aware
observability harus aman dari PII

2. State Inventory

Langkah pertama bukan menulis component. Langkah pertama adalah menginventarisasi state.

StateOwnerSource of truthPersistent?Shareable?
Keyword draftFilter formLocal stateNoNo
Committed keywordURLURL search paramsYes via URLYes
Status filterURLURL search paramsYes via URLYes
Priority filterURLURL search paramsYes via URLYes
SortURLURL search paramsYes via URLYes
PageURLURL search paramsYes via URLYes
Page sizeURL/user prefURL or profile prefOptionalYes if URL
Search resultsTanStack QueryServer/cacheCache lifecycleIndirect
Row selectionPage/table componentLocal or scoped storeNoNo
Detail modal openRoute or localURL/local decisionDependsMaybe
Saved viewsServer stateServer/cacheYesYes
Permission decisionsPermission provider/serverBackend policy snapshotSessionNo
Bulk mutation stateMutation hookTanStack Query mutationShort-livedNo
Observability contextProviderTelemetry clientNoNo

Rule:

If state must survive refresh/share/back-forward, prefer URL.
If state is authoritative remote data, use server-state cache.
If state is temporary UI draft, keep local.
If state is workflow progress, use reducer/state machine.
If state is cross-cutting capability, use context.

3. Architecture Diagram

Boundary design:

Page owns wiring.
URL hook owns parsing/canonicalization.
Query hook owns server-state fetching.
FilterPanel owns draft.
ResultTable owns row rendering and selection interaction.
BulkActionBar owns command intent.
Mutation hook owns command lifecycle and invalidation.
PermissionGate owns UX visibility/disable decision.

4. URL State Contract

Search params are API. Treat them like API.

Canonical params:

q       keyword, trimmed, omitted when empty
status  open|closed|escalated|all, omitted when all
priority low|medium|high|critical, omitted when all
assignee user id, omitted when none
from    ISO date yyyy-mm-dd, optional
to      ISO date yyyy-mm-dd, optional
sort    updatedAt.desc | dueDate.asc | priority.desc
page    positive integer, default 1
size    25 | 50 | 100, default 25

Important properties:

parse unknown values safely
canonicalize default values away
reset page when filter/sort changes
preserve unrelated params if route owns them
avoid putting sensitive keyword into telemetry raw

5. Search Param Parser

Gunakan parser eksplisit. Jangan baca search params ad-hoc di banyak component.

type CaseStatus = 'all' | 'open' | 'closed' | 'escalated'
type Priority = 'all' | 'low' | 'medium' | 'high' | 'critical'
type Sort = 'updatedAt.desc' | 'dueDate.asc' | 'priority.desc'

export type CaseSearchParams = {
  q: string
  status: CaseStatus
  priority: Priority
  assignee: string | null
  from: string | null
  to: string | null
  sort: Sort
  page: number
  size: 25 | 50 | 100
}

const DEFAULT_PARAMS: CaseSearchParams = {
  q: '',
  status: 'all',
  priority: 'all',
  assignee: null,
  from: null,
  to: null,
  sort: 'updatedAt.desc',
  page: 1,
  size: 25,
}

Parser:

function oneOf<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
  return value && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
}

function positiveInt(value: string | null, fallback: number): number {
  const parsed = Number(value)
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}

export function parseCaseSearchParams(params: URLSearchParams): CaseSearchParams {
  return {
    q: (params.get('q') ?? '').trim(),
    status: oneOf(params.get('status'), ['all', 'open', 'closed', 'escalated'] as const, 'all'),
    priority: oneOf(params.get('priority'), ['all', 'low', 'medium', 'high', 'critical'] as const, 'all'),
    assignee: params.get('assignee') || null,
    from: params.get('from') || null,
    to: params.get('to') || null,
    sort: oneOf(params.get('sort'), ['updatedAt.desc', 'dueDate.asc', 'priority.desc'] as const, 'updatedAt.desc'),
    page: positiveInt(params.get('page'), 1),
    size: oneOf(params.get('size'), ['25', '50', '100'] as const, '25') === '50'
      ? 50
      : oneOf(params.get('size'), ['25', '50', '100'] as const, '25') === '100'
        ? 100
        : 25,
  }
}

The size parser can be cleaner, but the point is explicit validation.

Better:

function pageSize(value: string | null): 25 | 50 | 100 {
  if (value === '50') return 50
  if (value === '100') return 100
  return 25
}

6. Serialization and Canonicalization

Serialization must remove defaults.

export function serializeCaseSearchParams(params: CaseSearchParams): URLSearchParams {
  const next = new URLSearchParams()

  if (params.q) next.set('q', params.q)
  if (params.status !== 'all') next.set('status', params.status)
  if (params.priority !== 'all') next.set('priority', params.priority)
  if (params.assignee) next.set('assignee', params.assignee)
  if (params.from) next.set('from', params.from)
  if (params.to) next.set('to', params.to)
  if (params.sort !== DEFAULT_PARAMS.sort) next.set('sort', params.sort)
  if (params.page !== 1) next.set('page', String(params.page))
  if (params.size !== 25) next.set('size', String(params.size))

  return next
}

Canonical URL matters because:

same search should map to same URL
same URL should map to same query key
unnecessary params should not create duplicate cache entries
copy/share should be stable
back button should be predictable

7. URL Hook

React Router useSearchParams returns current URLSearchParams and a setter; setting search params causes navigation.

export function useCaseSearchParams() {
  const [searchParams, setSearchParams] = useSearchParams()

  const parsed = useMemo(
    () => parseCaseSearchParams(searchParams),
    [searchParams],
  )

  const update = useCallback(
    (patch: Partial<CaseSearchParams>, options?: { resetPage?: boolean }) => {
      const next: CaseSearchParams = {
        ...parsed,
        ...patch,
        page: options?.resetPage ? 1 : patch.page ?? parsed.page,
      }

      setSearchParams(serializeCaseSearchParams(next))
    },
    [parsed, setSearchParams],
  )

  const reset = useCallback(() => {
    setSearchParams(serializeCaseSearchParams(DEFAULT_PARAMS))
  }, [setSearchParams])

  return { params: parsed, update, reset }
}

Caveat:

parsed object identity changes when searchParams changes.
Do not pass raw parsed object deep into memoized components unless needed.
Use domain callbacks at page boundary.

8. Draft vs Committed Filter State

Filter form needs local draft if typing should not update URL/query on every keystroke.

Pattern:

function FilterPanel({
  value,
  onApply,
  onReset,
}: {
  value: CaseSearchParams
  onApply(next: CaseSearchParams): void
  onReset(): void
}) {
  const [draft, setDraft] = useState(value)

  useEffect(() => {
    setDraft(value)
  }, [value])

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        onApply(draft)
      }}
    >
      <input
        value={draft.q}
        onChange={(event) => setDraft((prev) => ({ ...prev, q: event.target.value }))}
      />
      <button type="submit">Apply</button>
      <button type="button" onClick={onReset}>Reset</button>
    </form>
  )
}

But be careful: syncing props to local state via effect is usually suspicious. Here it is valid because FilterPanel intentionally has draft state that should reset when committed URL state changes externally.

Better contract:

FilterPanel receives committed value.
FilterPanel owns draft.
When committed value changes due browser back/share/reset, draft resyncs.

Two common designs:

DesignState flowBest for
Immediate searchinput → URL/query after debounceSimple keyword search
Apply searchinput → local draft → Apply → URL/queryComplex filters

Immediate search:

const [draftQ, setDraftQ] = useState(params.q)
const deferredQ = useDeferredValue(draftQ)

useEffect(() => {
  update({ q: deferredQ }, { resetPage: true })
}, [deferredQ, update])

This is often not ideal because it turns typing into navigation. For enterprise filters, Apply/Reset is clearer.

Rule:

Use local draft for complex filters.
Commit to URL only when user intent is clear.

10. Query Key Design

Query key is cache address.

export const caseSearchKeys = {
  all: ['cases'] as const,
  lists: () => [...caseSearchKeys.all, 'list'] as const,
  list: (params: CaseSearchParams) => [
    ...caseSearchKeys.lists(),
    {
      q: params.q,
      status: params.status,
      priority: params.priority,
      assignee: params.assignee,
      from: params.from,
      to: params.to,
      sort: params.sort,
      page: params.page,
      size: params.size,
    },
  ] as const,
  detail: (caseId: string) => [...caseSearchKeys.all, 'detail', caseId] as const,
}

Rules:

Every server-input variable belongs in the query key.
Do not put non-serializable objects in query key.
Canonicalize params before key generation.
Separate list and detail families.
Design keys for invalidation.

Bad:

useQuery({
  queryKey: ['cases'],
  queryFn: () => api.searchCases(params),
})

This causes unrelated searches to share the same cache entry.


11. Query Hook

export function useCaseSearchQuery(params: CaseSearchParams) {
  return useQuery({
    queryKey: caseSearchKeys.list(params),
    queryFn: ({ signal }) => api.searchCases(params, { signal }),
    staleTime: 30_000,
    placeholderData: (previous) => previous,
  })
}

Why placeholderData: previous => previous?

When page/filter changes, previous data can remain visible while next query loads.
This avoids table flicker.
But UI must show stale/pending affordance.

UI:

const query = useCaseSearchQuery(params)

<ResultTable
  rows={query.data?.items ?? []}
  loading={query.isPending}
  refetching={query.isFetching && !query.isPending}
  stale={query.isFetching && Boolean(query.data)}
/>

Distinguish:

StateUI meaning
isPendingno data yet for this query
isFetching with databackground/refetching/placeholder data
isErrorquery failed
empty itemssuccessful query with no results

12. Page Component

export function CaseSearchPage() {
  const { params, update, reset } = useCaseSearchParams()
  const query = useCaseSearchQuery(params)
  const permissions = useCasePermissions()
  const telemetry = useTelemetry()

  useEffect(() => {
    telemetry.info('case.search.params_changed', safeSearchParamTelemetry(params))
  }, [params, telemetry])

  return (
    <PageLayout title="Cases">
      <FilterPanel
        value={params}
        onApply={(next) => update(next, { resetPage: true })}
        onReset={reset}
      />

      <SearchSummary
        total={query.data?.total ?? 0}
        loading={query.isPending}
        refetching={query.isFetching && Boolean(query.data)}
      />

      {query.isError ? (
        <SearchErrorState onRetry={() => query.refetch()} />
      ) : (
        <CaseResultSection
          params={params}
          data={query.data}
          loading={query.isPending}
          refetching={query.isFetching && Boolean(query.data)}
          permissions={permissions}
          onPageChange={(page) => update({ page })}
          onSortChange={(sort) => update({ sort }, { resetPage: true })}
        />
      )}
    </PageLayout>
  )
}

Page owns wiring, not table details.


13. Result Section

function CaseResultSection({
  params,
  data,
  loading,
  refetching,
  permissions,
  onPageChange,
  onSortChange,
}: Props) {
  const selection = useCaseSelection(data?.items ?? [])

  if (loading) return <ResultSkeleton />

  if (!data || data.items.length === 0) {
    return <EmptySearchState />
  }

  return (
    <>
      {selection.selectedCount > 0 && (
        <BulkActionBar
          selectedIds={selection.selectedIds}
          canAssign={permissions.canBulkAssign}
          onClear={selection.clear}
        />
      )}

      <CaseTable
        rows={data.items}
        sort={params.sort}
        refetching={refetching}
        selection={selection}
        onSortChange={onSortChange}
      />

      <Pagination
        page={params.page}
        pageSize={params.size}
        total={data.total}
        onPageChange={onPageChange}
      />
    </>
  )
}

Selection state should not be global by default. It belongs to the result section/table unless bulk workflow must survive navigation.


14. Selection State

Selection keyed by row ID, not row index.

function useCaseSelection(visibleRows: CaseRow[]) {
  const visibleIds = useMemo(() => new Set(visibleRows.map((row) => row.id)), [visibleRows])
  const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())

  useEffect(() => {
    setSelectedIds((prev) => {
      const next = new Set<string>()
      for (const id of prev) {
        if (visibleIds.has(id)) next.add(id)
      }
      return next
    })
  }, [visibleIds])

  const toggle = useCallback((id: string) => {
    setSelectedIds((prev) => {
      const next = new Set(prev)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }, [])

  const clear = useCallback(() => setSelectedIds(new Set()), [])

  return {
    selectedIds: [...selectedIds],
    selectedCount: selectedIds.size,
    isSelected: (id: string) => selectedIds.has(id),
    toggle,
    clear,
  }
}

The pruning effect is valid because visible result changes are external to selection state. Alternative policy:

Selection survives pagination across whole query.
Selection prunes only when filter changes.
Selection belongs to a bulk workflow store.

Choose intentionally.


15. Pagination Reset Rules

Not all param changes should reset page.

ChangeReset page?Reason
KeywordYesResult set changed
StatusYesResult set changed
PriorityYesResult set changed
AssigneeYesResult set changed
Date rangeYesResult set changed
SortYesPage meaning changed
Page sizeUsually yesBoundaries changed
PageNoIt is the page change

Encode this in API:

onFilterApply={(next) => update(next, { resetPage: true })}
onSortChange={(sort) => update({ sort }, { resetPage: true })}
onPageChange={(page) => update({ page })}

Do not scatter page: 1 across components.


16. Sorting Contract

Sort is server-state input. It belongs in URL and query key.

<CaseTable
  sort={params.sort}
  onSortChange={(sort) => update({ sort }, { resetPage: true })}
/>

Bad pattern:

const [sort, setSort] = useState('updatedAt.desc')
const sortedRows = useMemo(() => sortRows(query.data.items, sort), [query.data, sort])

Unless the API explicitly returns all results and client sorting is correct, client sorting paginated server data is usually wrong.


17. Detail Modal State

Three options:

OptionState locationProsCons
Local modal stateselectedCaseId localSimpleNot shareable/back-button
URL query paramcaseId=...Shareable, back-buttonMore URL complexity
Nested route/cases/:idStrong route modelMore routing architecture

For search page with shareable detail:

/cases?status=open&page=2&caseId=case-123

Then:

const selectedCaseId = searchParams.get('caseId')

<CaseDetailModal
  caseId={selectedCaseId}
  open={Boolean(selectedCaseId)}
  onOpenChange={(open) => {
    if (!open) removeSearchParam('caseId')
  }}
/>

Detail query:

function useCaseDetailQuery(caseId: string | null) {
  return useQuery({
    queryKey: caseId ? caseSearchKeys.detail(caseId) : ['cases', 'detail', 'none'],
    queryFn: ({ signal }) => api.getCase(caseId!, { signal }),
    enabled: Boolean(caseId),
  })
}

Better avoid dummy key by only rendering query component when caseId exists.


18. Bulk Mutation

Bulk actions are command workflows.

function useBulkAssignCases() {
  const queryClient = useQueryClient()
  const telemetry = useTelemetry()

  return useMutation({
    mutationFn: api.bulkAssignCases,
    onMutate: async (input) => {
      const mutationId = crypto.randomUUID()
      telemetry.info('case.bulk_assign.started', {
        mutationId,
        selectedCount: input.caseIds.length,
      })
      return { mutationId }
    },
    onSuccess: (_data, input, context) => {
      telemetry.info('case.bulk_assign.succeeded', {
        mutationId: context?.mutationId ?? 'unknown',
        selectedCount: input.caseIds.length,
      })

      queryClient.invalidateQueries({ queryKey: caseSearchKeys.lists() })
    },
    onError: (error, input, context) => {
      telemetry.warn('case.bulk_assign.failed', {
        mutationId: context?.mutationId ?? 'unknown',
        selectedCount: input.caseIds.length,
        errorClass: classifyError(error),
      })
    },
  })
}

Invalidate list queries after bulk mutation because many list projections can change. If detail view is open, invalidate detail keys too.

queryClient.invalidateQueries({ queryKey: caseSearchKeys.lists() })
for (const caseId of input.caseIds) {
  queryClient.invalidateQueries({ queryKey: caseSearchKeys.detail(caseId) })
}

19. Optimistic Bulk Action?

Bulk actions are risky for optimistic UI.

Optimistic update is valid when:

operation is reversible or low-risk
server conflict is rare and easy to reconcile
user expects instant feedback
affected cache entries are identifiable

For regulatory cases, bulk close/assign may have permission/state constraints. Prefer conservative flow:

show pending state
disable affected action
wait for server result
invalidate/refetch
show result summary

If optimistic:

store previous cache snapshots
update visible list rows
record optimistic mutationId
rollback failed items only
show partial success/failure summary

20. Permission-Aware Actions

Do not hide everything blindly.

<PermissionGate
  decision={permissions.bulkAssign}
  fallback={(reason) => (
    <Button disabled title={reason.userMessage}>
      Assign selected
    </Button>
  )}
>
  <Button onClick={openAssignDialog}>Assign selected</Button>
</PermissionGate>

Rules:

Frontend permission improves UX.
Backend authorization remains final enforcement.
Telemetry may record reasonClass, not full policy internals.

21. Loading, Empty, Error, Refetching

Do not collapse all states into loading.

UI policy:

Query stateUI
Initial pendingSkeleton/full-page loading
Success emptyEmpty state with reset filters
Success non-emptyTable
Refetching with dataKeep table + subtle loading bar
Error without dataError state + retry
Error with previous dataKeep stale data + warning banner

This distinction prevents flicker and data disappearance.


22. Stale Data UX

When showing previous data during refetch, mark it.

{refetching && (
  <InlineStatus tone="neutral">
    Updating results…
  </InlineStatus>
)}

If the new params differ significantly, stale data can be misleading. For example:

User changed status=open to status=closed.
Old open cases remain visible while closed query loads.

Mitigation options:

OptionProsCons
Keep previous data + bannerSmoothCan confuse
Skeleton on major filter changeClearMore flicker
Dim previous tableBalancedMore UI complexity
Keep previous only for paginationOften bestNeeds policy

Production rule:

Use previous data for pagination/sort if acceptable.
Use stronger loading affordance for major filter changes.

23. Query Cancellation and Race

TanStack Query passes signal to queryFn. Use it.

async function searchCases(params: CaseSearchParams, options: { signal?: AbortSignal }) {
  const response = await fetch(`/api/cases?${serializeCaseSearchParams(params)}`, {
    signal: options.signal,
  })

  if (!response.ok) throw await toApiError(response)
  return response.json() as Promise<CaseSearchResult>
}

Avoid manual ignore flags for this page when Query manages lifecycle.

The more important race is semantic:

Mutation changes a row.
Search cache still shows old row.
User performs second action from stale row.

Fix with invalidation/direct cache update and disabled stale action policy.


24. Component Tree

CaseSearchPage
  SearchPageHeader
  FilterPanel
    KeywordInput
    StatusFilter
    PriorityFilter
    AssigneePicker
    DateRangeFilter
    ApplyResetButtons
  SearchSummary
  BulkActionBar
  CaseTable
    CaseTableHeader
    CaseTableRow
    RowActionMenu
  Pagination
  CaseDetailModal
  SaveViewDialog

Ownership:

CaseSearchPage: URL/query wiring
FilterPanel: draft filter state
CaseTable: visual rows + selection callbacks
ResultSection: selection state
BulkActionBar: bulk command intent
CaseDetailModal: detail query/workflow
SaveViewDialog: save view mutation

25. Avoid Prop Explosion with View Model

If table props explode, build a view model at boundary.

type CaseTableViewModel = {
  rows: CaseRowViewModel[]
  sort: Sort
  refetching: boolean
  selection: {
    selectedIds: string[]
    selectedCount: number
    isSelected(id: string): boolean
    toggle(id: string): void
  }
  actions: {
    canOpenDetail: boolean
    canAssign: boolean
    onOpenDetail(id: string): void
    onSortChange(sort: Sort): void
  }
}

Use view model to stabilize API, not to hide all complexity.

Bad:

<CaseTable everything={pageState} />

Good:

<CaseTable model={tableModel} />

where tableModel is a deliberately designed contract.


26. Performance Model

Search page performance cost comes from:

large table render
unstable row props
context fan-out
filter draft causing whole page rerender
expensive cell formatters
selection state rerendering all rows
non-virtualized large pages
excessive query result transformations

Mitigation:

keep page size reasonable
memoize row component only after profiling
use stable row IDs
pass primitive props to rows where possible
move high-frequency input draft into FilterPanel
avoid global context for selection
use virtualization for huge result sets
use selectors for external store if selection global

Row component:

const CaseTableRow = memo(function CaseTableRow({
  row,
  selected,
  onToggle,
  onOpen,
}: RowProps) {
  return (
    <tr aria-selected={selected}>
      <td>
        <input
          type="checkbox"
          checked={selected}
          onChange={() => onToggle(row.id)}
        />
      </td>
      <td>
        <button onClick={() => onOpen(row.id)}>{row.caseNumber}</button>
      </td>
      <td>{row.status}</td>
      <td>{row.priority}</td>
    </tr>
  )
})

But memo only helps if props are stable and render is actually expensive.


27. Accessibility Contract

Search page accessibility:

form inputs have labels
Apply/Reset are buttons with type
loading state announced politely
error state linked to retry action
table headers expose sort state
pagination has accessible labels
bulk selection announces count
modal traps focus and returns focus
disabled action explains reason if possible

Sort header:

<th aria-sort={sort === 'dueDate.asc' ? 'ascending' : 'none'}>
  <button onClick={() => onSortChange('dueDate.asc')}>Due date</button>
</th>

Bulk summary:

<div role="status" aria-live="polite">
  {selectedCount} cases selected
</div>

28. Observability for Search Page

Safe telemetry:

function safeSearchParamTelemetry(params: CaseSearchParams) {
  return {
    status: params.status,
    priority: params.priority,
    hasKeyword: params.q.length > 0,
    keywordLength: params.q.length,
    page: params.page,
    size: params.size,
    sort: params.sort,
    activeFilterCount: countActiveFilters(params),
  }
}

Events:

case.search.page_opened
case.search.params_changed
case.search.filters_applied
case.search.filters_reset
case.search.query_started
case.search.query_succeeded
case.search.query_failed
case.search.empty_state_shown
case.search.results_rendered
case.search.page_changed
case.search.sort_changed
case.bulk_assign.started
case.bulk_assign.succeeded
case.bulk_assign.failed
case.detail.opened
case.saved_view.created

Metrics:

query duration by status/priority/filterCount
query failure rate by errorClass
result count bucket
render duration by row count bucket
bulk mutation failure rate
empty state rate

29. Testing Plan

Test parser/serializer:

it('canonicalizes default params away', () => {
  const params = serializeCaseSearchParams(DEFAULT_PARAMS)
  expect(params.toString()).toBe('')
})

it('falls back on invalid params', () => {
  const params = parseCaseSearchParams(new URLSearchParams('page=-10&status=wat'))
  expect(params.page).toBe(1)
  expect(params.status).toBe('all')
})

Test page behavior:

render with URL params → inputs/table reflect committed state
change draft keyword → URL does not change before Apply
Apply filters → URL updates, page resets to 1
change page → URL page changes, filters preserved
invalid URL param → canonical/default UI state
query error → error state with retry
empty response → empty state
bulk assign success → list query invalidated
bulk assign failure → error toast shown, selection preserved/cleared per policy
browser back → filter draft resyncs

Test query key:

expect(caseSearchKeys.list({ ...DEFAULT_PARAMS, status: 'open' })).not.toEqual(
  caseSearchKeys.list({ ...DEFAULT_PARAMS, status: 'closed' }),
)

30. Failure Modes

Failure modeSymptomRoot causeFix
Query key too broadwrong results reusedmissing params in keyinclude all server inputs
Query key too noisycache explosionnon-canonical paramscanonicalize defaults
Page not resetempty/wrong page after filterfilter changes keep old pagereset page on filter/sort
Draft overwrites URLback button weirdlocal draft sync wrongexplicit draft/commit contract
Selection by indexwrong rows selectedpagination/sort changeskey by row ID
Hidden stale datauser acts on old rowprevious data no affordanceshow refetching/stale state
Client sort server pageincorrect global ordersorting partial dataserver-side sort
Full keyword telemetryPII leakraw search loggingsafe metadata only
Over-global stateunrelated rerendersstore/context too broadlocalize selection/draft
Duplicate fetcheffect-based fetchingmanual fetch lifecycleserver-state cache
Modal not shareablecopied URL loses contextlocal detail stateURL/nested route detail state
Invalid params crashURL parser trusts inputno validationparser with fallbacks
Bulk invalidation gaplist shows old rowsmutation did not invalidateinvalidate list/detail families
Permission driftbutton allowed but server deniesstale UI policybackend enforcement + refetch permission
Render slowtable janklarge rows/context fan-outprofile, memo, virtualize

31. Refactor Ladder

When the page grows:

1. Inline useSearchParams parsing
2. Extract parse/serialize helpers
3. Extract useCaseSearchParams
4. Extract query key factory
5. Extract useCaseSearchQuery
6. Extract FilterPanel draft state
7. Extract ResultSection selection state
8. Extract mutation hooks
9. Extract view model builders
10. Extract workflow machine for bulk/detail flows
11. Add observability adapter
12. Add Storybook state matrix

Do not jump to step 12 on day one. But design step 1 so it can migrate.


32. ADR Example

# ADR: Case Search State Ownership

## Context
The case search page must support shareable filters, browser back/forward, server pagination, bulk actions, and saved views.

## Decision
Committed search state will live in URL search params.
Filter panel will own local draft state.
Search results will be managed by TanStack Query.
Selection state will be local to the result section and keyed by case ID.
Bulk mutations will invalidate case list and affected detail query families.

## Consequences
Users can share URLs.
Back/forward works for committed filters.
Draft filter typing does not trigger query.
Selection does not survive filter changes.
Query key design becomes part of API contract.

33. Production Checklist

[ ] Search params parsed and validated centrally
[ ] Defaults canonicalized out of URL
[ ] Query key includes all server inputs
[ ] Query key excludes non-server UI state
[ ] Filter changes reset page
[ ] Draft filter state separated from committed URL state
[ ] Query uses AbortSignal where supported
[ ] Loading/empty/error/refetching states distinct
[ ] Previous/stale data affordance intentional
[ ] Selection keyed by stable ID
[ ] Bulk mutation invalidates impacted query families
[ ] Permission UI backed by server enforcement
[ ] Telemetry safe from PII
[ ] Parser/serializer tested
[ ] Query/mutation behavior tested
[ ] Accessibility states tested
[ ] Performance profiled before memoization/virtualization

34. Key Takeaways

Complex search page is a state topology problem, not a table problem.
URL owns committed shareable search state.
Local state owns draft filter input.
TanStack Query owns server result cache.
Query keys are cache schema and must be designed intentionally.
Pagination reset rules are part of state contract.
Selection must be keyed by stable IDs, not row index.
Stale/previous data must be visible as a UX state.
Mutations must define invalidation and optimistic policy.
Permission-aware UI is UX, not final security enforcement.
Observability must record metadata and correlation, not sensitive payload.

References

Lesson Recap

You just completed lesson 114 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.