Learning path · Level 10 · Tutorial 2

Represent Detail Views and Filters in the URL

Make record selection and list filters reproducible through the URL. Handle invalid values and ensure that direct navigation, refresh, and browser history restore the intended view.

  • Level: Intermediate
  • Duration: 20 min
  • 8 steps
Start tutorial Back to the path
Represent Detail Views and Filters in the URL

What you will go through

  1. Decide What the URL Should Represent Decide which Issue Desk values belong in route parameters, search parameters, or temporary interaction state, then create a minimal local route shell. 2 min
  2. Read and Validate a Detail Identifier Read the `ticketId` route parameter and convert it into either a validated string identifier or an explicit invalid result before selecting a ticket. 3 min
  3. Distinguish Invalid IDs from Missing Tickets Render distinct feedback for malformed identifiers, valid identifiers with no matching record, and matching tickets. 2 min
  4. Parse Supported Filters from the URL Convert ticket-list search-parameter values into a supported status filter and derive the matching records. 3 min
  5. Update a Filter Without Losing Other URL Values Build a URL-controlled status filter that preserves unrelated search parameters and reconstructs its view after direct navigation or refresh. 3 min
  6. Choose Meaningful History Entries Choose push or replace intentionally so browser history reflects the meaning of each URL-based filter update. 2 min
  7. Keep Filter Focus Stable Keep keyboard focus on the URL-controlled Status filter while preserving deliberate heading focus for client-side pathname navigation. 2 min
  8. Verify a Reproducible URL-Driven View Verify that Issue Desk rebuilds detail and filter views from URL state, including invalid values, refresh, browser history, unrelated parameters, and keyboard focus. 3 min

What you will learn

  • Read and validate a route parameter before selecting a detail record.
  • Render distinct feedback for an invalid identifier and an identifier with no matching record.
  • Drive a filter control from search parameters while preserving unrelated URL values.
  • Choose push or replace updates and verify restored filters through refresh and browser history.
  • Keep focus on a filter control during search-parameter updates.

Before you start

  • Build Accessible Client-Side Routes with React Router
  • Narrow Unions and Nullable Values
  • Build Controlled Forms in React
  • Fetch and Validate JSON Responses

Step 1 of 8

Decide What the URL Should Represent

Decide which Issue Desk values belong in route parameters, search parameters, or temporary interaction state, then create a minimal local route shell.

Model the view in the URL

A reproducible view

URL state makes a view reproducible

In Issue Desk, an address can describe which ticket someone is viewing and which subset of tickets they chose to see. A person can copy that address, open it in a new tab, refresh it, or return to it with browser history.

Use a route parameter for the identity of one addressable ticket, such as /tickets/2. Use search parameters for a list view, such as /tickets?status=open.

The URL is authoritative for these values. A component derives its displayed ticket or filter from URL state instead of keeping a second React state value that could become unsynchronized.

What belongs in the address

Diagram showing a URL path connected to one ticket, URL query values connected to a filtered list, and focus and hover shown as separate local interactions.

A ticket identity and a shareable list filter belong in URL state. Hover and current keyboard focus are temporary interaction details.

Classify ticket-view values

Choose the right location

Use this decision rule:

  • Put a value in a route parameter when it identifies a resource that has its own address.
  • Put a value in search parameters when it describes a reproducible variation of the current list.
  • Keep a value out of the URL when it only describes what is happening during this moment of interaction.

For example, source=guide may appear beside the status filter in an address. It does not change the displayed tickets, but it is still URL state that later filter changes must not discard.

Issue Desk examples

These examples use the URL conventions for this application. The filter's default view will later omit status from the address.

State location follows whether a value needs a stable, shareable address.
ValueLocationExample
Selected ticketRoute parameter/tickets/2
Open-ticket listSearch parameter/tickets?status=open
Guide sourceSearch parametersource=guide
Keyboard focusNon-URL interaction stateFocused Status control
Pointer hoverNon-URL interaction stateHovered ticket link

Set up the route shell

Create the local practice app

Create a local Vite React TypeScript app, install React Router, and start its development server. Then replace the generated src/main.tsx and src/App.tsx files and add src/tickets.ts with the complete files below.

Each route view owns its page's single <main> landmark. App only chooses which route view renders, so later detail and list updates do not create nested main landmarks. This shell deliberately defines the list and detail addresses before it implements selection or filtering.

Terminal commands

bash
npm create vite@latest issue-desk -- --template react-ts
cd issue-desk
npm install
npm install react-router
npm run dev
Expected output
> issue-desk@0.0.0 dev
> vite

  VITE v7.x.x  ready in ... ms

  ➜  Local:   http://localhost:5173/
  ➜  press h + enter to show help

The Vite version and local port can vary. Open the Local address printed by Vite.

src/main.tsx

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

src/tickets.ts

typescript
export type Ticket = {
  id: string;
  title: string;
  status: 'open' | 'closed';
};

export const tickets: Ticket[] = [
  { id: '1', title: 'Fix navigation', status: 'open' },
  { id: '2', title: 'Improve contrast', status: 'closed' },
  { id: '3', title: 'Update help', status: 'open' },
];

src/App.tsx

tsx
import { Link, Route, Routes } from 'react-router';
import { tickets } from './tickets';

export function TicketList() {
  return (
    <main>
      <h1>Tickets</h1>
      <ul>
        {tickets.map((ticket) => (
          <li key={ticket.id}>
            <Link to={`/tickets/${ticket.id}`}>{ticket.title}</Link>
            {' — '}{ticket.status}
          </li>
        ))}
      </ul>
    </main>
  );
}

export function TicketDetail() {
  return (
    <main>
      <h1>Ticket detail</h1>
      <p>This route will read its ticket ID in the next step.</p>
      <Link to='/tickets'>Back to tickets</Link>
    </main>
  );
}

export default function App() {
  return (
    <Routes>
      <Route path='/tickets' element={<TicketList />} />
      <Route path='/tickets/:ticketId' element={<TicketDetail />} />
    </Routes>
  );
}

Check your URL-state decisions

Place URL-owned and transient values

Match each Issue Desk value to the place where it belongs.

Tap an item and then its matching pair.

Step 2 of 8

Read and Validate a Detail Identifier

Read the ticketId route parameter and convert it into either a validated string identifier or an explicit invalid result before selecting a ticket.

Route values need a validation boundary

Validate before lookup

A detail URL uses the dynamic segment /tickets/:ticketId. For example, opening /tickets/2 lets useParams supply the raw ticketId value.

That value is URL state, so do not assume it is already a usable record ID. Its type is string | undefined. First parse it into one of two results:

  • the original, validated string ID
  • null when the value is missing or malformed

This boundary answers only “does this value have the required ID format?” A well-formed value such as 999 remains a valid string even if no ticket has that ID. Record lookup and its interface outcomes come next.

The identifier validation boundary

A detail URL parameter passes through validation and becomes either a valid string ID or null before a separate ticket lookup stage.

Validation checks identifier shape first; a later lookup checks whether that valid identifier has a matching ticket.

Read and parse the route parameter

Match the named segment

The route declaration from the starting app already uses the same segment name that TicketDetail reads. The name after : and the destructured parameter name must match exactly.

Declare the detail route

src/App.tsx — existing route declaration

tsx
<Routes>
  <Route path='/tickets' element={<TicketList />} />
  <Route path='/tickets/:ticketId' element={<TicketDetail />} />
</Routes>

Parse a raw ticket ID

src/tickets.ts — replace the current file with this version. Keeping the parser exported here lets the detail component use the same ticket-ID rule as later detail logic.

typescript
export type Ticket = {
  id: string;
  title: string;
  status: 'open' | 'closed';
};

export const tickets: Ticket[] = [
  { id: '1', title: 'Fix navigation', status: 'open' },
  { id: '2', title: 'Improve contrast', status: 'closed' },
  { id: '3', title: 'Update help', status: 'open' },
];

export function parseTicketId(raw: string | undefined): string | null {
  if (raw === undefined || raw.trim() !== raw) {
    return null;
  }

  return /^[1-9][0-9]*$/.test(raw) ? raw : null;
}

Read the parameter in TicketDetail

src/App.tsx — add useParams and parseTicketId to the existing imports, then replace only TicketDetail with this temporary parsing version. It deliberately does not look up a ticket yet.

tsx
import { Link, Route, Routes, useParams } from 'react-router';
import { parseTicketId, tickets } from './tickets';

export function TicketDetail() {
  const { ticketId } = useParams();
  const id = parseTicketId(ticketId);

  return (
    <main>
      <h1>Ticket detail</h1>
      <p>
        {id === null
          ? 'The route does not contain a usable ticket identifier.'
          : 'The route contains an identifier ready for lookup.'}
      </p>
      <Link to='/tickets'>Back to tickets</Link>
    </main>
  );
}

Tip

Keep valid IDs as strings

The guard narrows away undefined before calling trim(). Then the anchored pattern requires the entire value to be a positive decimal string: no whitespace, letters, partial values, zero, or leading zeros.

Keep the successful value as its original string. Ticket IDs are strings, so a later lookup can compare candidate.id === id. Do not use partial numeric parsing: parseInt('2x', 10) would incorrectly turn malformed URL state into 2.

Practice: validate without looking up

Complete the validation flow

Complete the parameter-reading and validation flow. It must reject malformed values such as abc, 2x, and 01, while allowing a well-formed but unknown identifier to continue as a string.

tsx
function parseTicketId(raw: string | undefined): string | null {
  if () {
    return null;
  }

  return .test(raw) ? raw : null;
}

export function TicketDetail() {
  const {  } = useParams();
  const id = ();

  return <p>{id === null ? 'Not ready for lookup.' : 'Ready for lookup.'}</p>;
}

Step 3 of 8

Distinguish Invalid IDs from Missing Tickets

Render distinct feedback for malformed identifiers, valid identifiers with no matching record, and matching tickets.

Recognize the detail outcomes

Three outcomes in one detail route

A URL can match the detail route and still fail to identify a ticket. TicketDetail has three separate outcomes:

  • /tickets/abc has a malformed route parameter. Render Invalid ticket ID and stop before lookup.
  • /tickets/999 has a well-formed ID, but no local record matches it. Render Ticket not found.
  • /tickets/2 has a well-formed ID that matches a record. Render that ticket’s details.

Do not substitute a default ticket. These are outcomes inside /tickets/:ticketId; they are not the wildcard route for an unmatched application path.

Parse before looking up

A three-branch diagram showing malformed input stopping before lookup, a valid unknown ID producing no result, and a valid matching ID opening a ticket.

Validation failure and lookup failure are different outcomes.

Tip

Provide a recovery link

/tickets/abc already matches /tickets/:ticketId, so it should not render a wildcard-route message. Each detail outcome includes a visible link to the ticket list. That gives someone who opened or shared an address a recovery path without requiring a previous browser-history entry.

Build explicit feedback branches

src/tickets.ts

The parser must be exported from src/tickets.ts so TicketDetail can import it. Replace that file with this complete version if your local file does not already contain the exported parser from the previous step.

typescript
export type Ticket = {
  id: string;
  title: string;
  status: 'open' | 'closed';
};

export const tickets: Ticket[] = [
  { id: '1', title: 'Fix navigation', status: 'open' },
  { id: '2', title: 'Improve contrast', status: 'closed' },
  { id: '3', title: 'Update help', status: 'open' },
];

export function parseTicketId(raw: string | undefined): string | null {
  if (raw === undefined || raw.trim() !== raw) {
    return null;
  }

  return /^[1-9][0-9]*$/.test(raw) ? raw : null;
}

src/App.tsx

Replace all of src/App.tsx with this version. Because it is a complete file, its import block replaces the earlier one rather than adding a second Link import.

tsx
import { Link, Route, Routes, useParams } from 'react-router';
import { parseTicketId, tickets } from './tickets';

export function TicketList() {
  return (
    <main>
      <h1>Tickets</h1>
      <ul>
        {tickets.map((ticket) => (
          <li key={ticket.id}>
            <Link to={`/tickets/${ticket.id}`}>{ticket.title}</Link>
            {' — '}{ticket.status}
          </li>
        ))}
      </ul>
    </main>
  );
}

export function TicketDetail() {
  const { ticketId } = useParams();
  const id = parseTicketId(ticketId);

  if (id === null) {
    return (
      <main>
        <h1>Invalid ticket ID</h1>
        <p>The ticket address has an unsupported identifier.</p>
        <Link to='/tickets'>Back to tickets</Link>
      </main>
    );
  }

  const ticket = tickets.find((candidate) => candidate.id === id);

  if (ticket === undefined) {
    return (
      <main>
        <h1>Ticket not found</h1>
        <p>No ticket has this identifier.</p>
        <Link to='/tickets'>Back to tickets</Link>
      </main>
    );
  }

  return (
    <main>
      <h1>{ticket.title}</h1>
      <p>Status: {ticket.status}</p>
      <Link to='/tickets'>Back to tickets</Link>
    </main>
  );
}

export default function App() {
  return (
    <Routes>
      <Route path='/tickets' element={<TicketList />} />
      <Route path='/tickets/:ticketId' element={<TicketDetail />} />
    </Routes>
  );
}

Why this branch order works

useParams runs before every conditional return. Once id === null returns, the lookup receives only a validated string. The second branch then distinguishes an absent record from a malformed route parameter. At the final return, ticket is narrowed to an existing ticket.

Practice: classify route outcomes

Classify detail addresses

Match each direct detail address to the outcome that TicketDetail renders.

Tap an item and then its matching pair.

Apply: complete the branches

Apply the three-outcome logic

Complete the conditional branches so the three standard direct detail addresses retain distinct outcomes.

tsx
const id = parseTicketId(ticketId);

if (id === null) {
  return <h1></h1>;
}

const ticket = tickets.find((candidate) => candidate.id === id);

if () {
  return <h1></h1>;
}

return <h1>{ticket.title}</h1>;

Step 4 of 8

Parse Supported Filters from the URL

Convert ticket-list search-parameter values into a supported status filter and derive the matching records.

Treat the search parameter as untrusted input

From address to visible tickets

A list filter is URL state: the address supplies a raw string, the app parses it into one supported value, and the visible tickets are derived from that value.

For this list, the supported values are all, open, and closed. Do not trust a URL string merely because TypeScript knows it is a string. Parse it first, then use the parsed StatusFilter to filter the ticket array.

A flow diagram showing a status value from a ticket-list URL being parsed before determining which ticket cards appear.

Read the raw URL value, parse it into a supported filter, then derive the displayed list.

Parse before filtering

Define one parsing rule

searchParams.get('status') returns string | null: a string when the key is present, or null when it is absent. If status appears more than once, get uses its first value. That is the explicit read policy for this app.

Missing, empty, and unsupported values all fall back to all. Merely reading an unsupported value does not rewrite the address; the URL remains unchanged until a later deliberate update.

Parse only supported status values

Add this union and parser near the top of the module that contains TicketList. The checks narrow raw to one of the supported string literals, so returning raw is type-safe.

tsx
export type StatusFilter = 'all' | 'open' | 'closed';

export function parseStatus(raw: string | null): StatusFilter {
  if (raw === 'open' || raw === 'closed' || raw === 'all') {
    return raw;
  }

  return 'all';
}

URL inputs and parsed results

The derived count uses the three local Issue Desk tickets: two are open and one is closed.

The first `status` value is used when the key is repeated.
URL status inputParsed statusVisible tickets
Missingall3
Empty valueall3
openopen2
closedclosed1
allall3
pausedall3
closed, then openclosed1

Read, parse, and derive on each render

Derive the list from the parsed filter

In TicketList, read the URL on every render, parse its status value, and calculate the matching tickets. There is no second component state value to synchronize: direct navigation and refresh reconstruct this view from the URL.

The parsed status will also become the controlled select value in the next step.

Read and filter in TicketList

Use this TicketList body after importing useSearchParams from react-router, importing tickets from ./tickets, and adding parseStatus above the component.

tsx
export function TicketList() {
  const [searchParams] = useSearchParams();
  const status = parseStatus(searchParams.get('status'));
  const visibleTickets = tickets.filter((ticket) => {
    return status === 'all' || ticket.status === status;
  });

  return (
    <main>
      <h1>Tickets</h1>
      <p>{visibleTickets.length} tickets shown</p>
      <ul>
        {visibleTickets.map((ticket) => (
          <li key={ticket.id}>
            {ticket.title} — {ticket.status}
          </li>
        ))}
      </ul>
    </main>
  );
}

Tip

Why this stays reproducible

The filter operation is derived during rendering. When the parsed value is all, every ticket is included; otherwise, only tickets whose status matches are included.

Practice: complete the supported filter parser

Complete the parser and derived count

Complete the parser's two supported non-default literals. The completed function must make countVisible derive the correct result for supported, missing, empty, and unsupported URL values.

tsx
function parseStatus(raw: string | null): StatusFilter {
  if (raw === '' || raw === '' || raw === 'all') {
    return raw;
  }

  return 'all';
}

function countVisible(raw: string | null): number {
  const status = parseStatus(raw);

  return tickets.filter((ticket) => {
    return status === 'all' || ticket.status === status;
  }).length;
}

Step 5 of 8

Update a Filter Without Losing Other URL Values

Build a URL-controlled status filter that preserves unrelated search parameters and reconstructs its view after direct navigation or refresh.

Drive the select from the URL

Make URL state control the filter

The Status select has one source of truth: the parsed status value from the search parameters. Its value comes directly from that value, and the displayed tickets are derived from it.

Do not add a second useState value or an Effect to synchronize the select. When someone directly opens a URL or refreshes it, useSearchParams supplies the URL state again, so the control and list reconstruct together.

One URL state, two derived views

A diagram where search parameters drive both a status dropdown and a filtered ticket list, while an unrelated parameter stays unchanged.

The parsed status value controls the select and the visible tickets. Updating status must retain unrelated URL values.

Change only the parameter you own

A filter control owns only the status key. Clone the current search parameters first, then change that clone:

  • For Open or Closed, set status.
  • For All tickets, delete status so the default URL stays compact.
  • Commit the complete clone in one setSearchParams call.

Starting from /tickets?status=open&source=guide, choosing All tickets must leave source=guide in the address. Do not mutate the searchParams object that the hook returned.

Preserve the complete URL context

Update the list with merged imports

In src/App.tsx, replace the existing React and router import lines, the StatusFilter/parseStatus declarations, and TicketList with this consolidated version. StatusFilter and parseStatus stay in this module, where Step 4 introduced them; they are not imported from src/tickets.ts. The merged router import prevents duplicate Link bindings.

URL-controlled TicketList

src/App.tsx — replacement imports, parser, and TicketList

tsx
import { type ChangeEvent } from 'react';
import {
  Link,
  Route,
  Routes,
  useLocation,
  useParams,
  useSearchParams,
} from 'react-router';
import { parseTicketId, tickets } from './tickets';

export type StatusFilter = 'all' | 'open' | 'closed';

export function parseStatus(raw: string | null): StatusFilter {
  if (raw === 'open' || raw === 'closed' || raw === 'all') {
    return raw;
  }

  return 'all';
}

export function TicketList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const status = parseStatus(searchParams.get('status'));
  const visibleTickets = tickets.filter((ticket) => {
    return status === 'all' || ticket.status === status;
  });

  function handleStatusChange(event: ChangeEvent<HTMLSelectElement>) {
    const nextStatus = parseStatus(event.currentTarget.value);
    const next = new URLSearchParams(searchParams);

    if (nextStatus === 'all') {
      next.delete('status');
    } else {
      next.set('status', nextStatus);
    }

    setSearchParams(next);
  }

  const currentSearch = searchParams.toString();

  return (
    <main>
      <h1>Tickets</h1>
      <label>
        Status
        <select value={status} onChange={handleStatusChange}>
          <option value='all'>All tickets</option>
          <option value='open'>Open</option>
          <option value='closed'>Closed</option>
        </select>
      </label>
      <p>{visibleTickets.length} tickets shown</p>
      <ul>
        {visibleTickets.map((ticket) => (
          <li key={ticket.id}>
            <Link
              to={{
                pathname: `/tickets/${ticket.id}`,
                search: currentSearch === '' ? '' : `?${currentSearch}`,
              }}
            >
              {ticket.title}
            </Link>
            {' — '}{ticket.status}
          </li>
        ))}
      </ul>
    </main>
  );
}

Keep filter context in detail links

Detail links carry the current search parameters, and every return link carries them back to the list. This keeps a filtered context available even when a detail URL is opened directly, rather than relying on a previous browser-history entry. Call useLocation before the conditional returns, alongside the existing useParams call.

Preserve search parameters through details

src/App.tsx — replace TicketDetail with this version

tsx
export function TicketDetail() {
  const { ticketId } = useParams();
  const location = useLocation();
  const id = parseTicketId(ticketId);
  const listTarget = {
    pathname: '/tickets',
    search: location.search,
  };

  if (id === null) {
    return (
      <main>
        <h1>Invalid ticket ID</h1>
        <p>The ticket address has an unsupported identifier.</p>
        <Link to={listTarget}>Back to tickets</Link>
      </main>
    );
  }

  const ticket = tickets.find((candidate) => candidate.id === id);

  if (ticket === undefined) {
    return (
      <main>
        <h1>Ticket not found</h1>
        <p>No ticket has this identifier.</p>
        <Link to={listTarget}>Back to tickets</Link>
      </main>
    );
  }

  return (
    <main>
      <h1>{ticket.title}</h1>
      <p>Status: {ticket.status}</p>
      <Link to={listTarget}>Back to tickets</Link>
    </main>
  );
}

Practice the safe update

Update one owned key

Complete the safe search-parameter update.

tsx
function handleStatusChange(event: ChangeEvent<HTMLSelectElement>) {
  const nextStatus = parseStatus(event.currentTarget.value);
  const next = new URLSearchParams(searchParams);

  if (nextStatus === 'all') {
    next.('status');
  } else {
    next.('status', nextStatus);
  }

  setSearchParams();
}

Verify the reproducible view

Verify direct navigation and refresh

Run your local Issue Desk app and directly enter /tickets?status=open&source=guide. Refresh the page. Then choose All tickets, copy the resulting address into a new tab or refresh it.

In 4–6 short lines, report both addresses, the selected Status label after each check, the ticket titles shown after each check, and whether source=guide remained after selecting All tickets.

Write at least 100 characters (0/100).

Step 6 of 8

Choose Meaningful History Entries

Choose push or replace intentionally so browser history reflects the meaning of each URL-based filter update.

Give each URL update a history meaning

Push and replace have different histories

A history update is a navigation decision. A push adds a new URL state after the current one. A replace changes the current URL state in place.

For Issue Desk, a user choosing a committed Status value such as Open or Closed should usually be able to revisit that choice with Back. Use push for those selections.

Use replace when a change is corrective or intermediate and should not create another Back destination. The URL remains authoritative: when Back or Forward restores an address, TicketList reads its search parameters again and renders the matching filter.

A comparison diagram: push creates successive browser-history positions, while replace overwrites the current position.

Push preserves a sequence to revisit; replace intentionally does not.

Choose based on the user meaning, not on whether the URL happens to change.

History policy for URL state updates
SituationUpdateBack behavior
User commits Open, then ClosedPushReturns through Open
Correct an existing URL valueReplaceSkips the corrected value
Intermediate editReplaceAvoids extra destinations

Use push for committed selections

Push committed status selections

Keep the existing cloning and status-update logic in TicketList. The final setter determines the history policy. This version pushes a committed filter selection, so it creates a revisitable history entry while retaining unrelated values already copied into next.

tsx
const next = new URLSearchParams(searchParams)

if (nextStatus === 'all') {
  next.delete('status')
} else {
  next.set('status', nextStatus)
}

setSearchParams(next)

Tip

To replace instead, keep the same next parameters and pass { replace: true } as the second argument. Do not add component state or a custom browser-history listener: a restored URL already supplies the filter that the list renders.

Use replace for a non-destination

An intermediate correction should update the current history entry rather than add a new Back destination. Complete the setter argument.

Replace an intermediate update

Use the code exercise below to supply the missing setter option.

Complete the call for an intermediate URL correction.

tsx
setSearchParams(next)

Check Back and Forward locally

Verify a push-based filter sequence

Run your local Issue Desk app. Start at /tickets?source=guide, then use the Status control to choose Open and then Closed.

In 4–6 short lines, report:

  1. the update policy used for these committed selections;
  2. the three chronological URL states, including the preserved source=guide value;
  3. what the first Back action restores;
  4. what Forward restores afterward.

Use the browser controls to verify your prediction rather than relying only on the code.

Write at least 120 characters (0/120).

Step 7 of 8

Keep Filter Focus Stable

Keep keyboard focus on the URL-controlled Status filter while preserving deliberate heading focus for client-side pathname navigation.

Separate a filter update from page navigation

Two kinds of URL state change

A Status change updates search parameters while the pathname stays /tickets. It changes the current list view, but it is not page-level navigation.

When a keyboard user changes Status, the same <select> should remain mounted and keep focus while the URL state and visible tickets update.

A detail change is different. /tickets/1 to /tickets/2 changes the pathname and selects a different addressable record. Treat it as page-level navigation and deliberately focus the new page heading.

Focus follows the URL change

Comparison diagram showing focus remaining on a status select for a search-parameter change and moving to a detail heading for a pathname change.

Search-parameter updates retain control focus; pathname changes use heading focus.

Tip

Keep the control stable

Do not add a key derived from the search string to TicketList, its form, or its <select>. A changed key remounts the element and replaces the focused DOM node.

Also, do not add an Effect that refocuses the select after each filter update. The browser retains native focus when the select keeps its identity.

Use pathname as the focus trigger

Add the route-focus implementation

The current Issue Desk files do not yet contain the heading-focus code, so add it now in src/App.tsx. This implementation preserves the initial-load guard: entering an address directly does not move focus. It reacts only after a client-side pathname change.

Make every change in this screen. The heading ref is passed to each route component so the effect in App always focuses the heading that is currently rendered.

Imports and shared heading prop

Replace the current React and router imports in src/App.tsx, then add this type below the imports.

tsx
import {
  type ChangeEvent,
  type RefObject,
  useEffect,
  useRef,
} from 'react';
import {
  Link,
  Route,
  Routes,
  useLocation,
  useParams,
  useSearchParams,
} from 'react-router';

type RouteHeadingProps = {
  headingRef: RefObject<HTMLHeadingElement | null>;
};

Attach the ref to each route heading

Change both route-component signatures and replace their existing <h1> elements with the corresponding focusable headings.

tsx
export function TicketList({ headingRef }: RouteHeadingProps) {
  // Keep the existing search-parameter and select logic.
  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1}>Tickets</h1>
      {/* Keep the existing Status label, select, count, and list. */}
    </main>
  );
}

export function TicketDetail({ headingRef }: RouteHeadingProps) {
  // Keep the existing useParams, useLocation, parser, and branches.
  // Use this form for all three existing headings:
  // <h1 ref={headingRef} tabIndex={-1}>Invalid ticket ID</h1>
  // <h1 ref={headingRef} tabIndex={-1}>Ticket not found</h1>
  // <h1 ref={headingRef} tabIndex={-1}>{ticket.title}</h1>
}

Focus only after a pathname change

Replace the existing App function with this version. It initializes previousPathname from the current pathname, so the first render returns without focusing anything.

tsx
export default function App() {
  const { pathname } = useLocation();
  const headingRef = useRef<HTMLHeadingElement>(null);
  const previousPathname = useRef(pathname);

  useEffect(() => {
    if (previousPathname.current === pathname) {
      return;
    }

    previousPathname.current = pathname;
    headingRef.current?.focus();
  }, [pathname]);

  return (
    <Routes>
      <Route path='/tickets' element={<TicketList headingRef={headingRef} />} />
      <Route
        path='/tickets/:ticketId'
        element={<TicketDetail headingRef={headingRef} />}
      />
    </Routes>
  );
}

Make the detail test realizable

Provide a client-side detail journey

To test detail-to-detail navigation without a full page load, add links inside the successful-ticket return of TicketDetail. Place this <nav> after <p>Status: {ticket.status}</p> and before the existing Back to tickets link.

The links retain the current search parameters, just as the existing return link does. From /tickets/1, the Improve contrast link provides a client-side navigation to /tickets/2.

Link to another ticket detail

Insert this navigation in the final successful-ticket branch of TicketDetail, where ticket is already known.

tsx
<nav aria-label='Other tickets'>
  <ul>
    {tickets
      .filter((candidate) => candidate.id !== ticket.id)
      .map((candidate) => (
        <li key={candidate.id}>
          <Link
            to={{
              pathname: `/tickets/${candidate.id}`,
              search: location.search,
            }}
          >
            {candidate.title}
          </Link>
        </li>
      ))}
  </ul>
</nav>

Tip

What the guard preserves

Changing /tickets?status=open&source=guide to another filter value rerenders TicketList, but its pathname remains /tickets. The Effect does not run, and the mounted Status select remains the active element.

By contrast, clicking Improve contrast from /tickets/1 changes the pathname to /tickets/2. The detail heading is newly rendered, then the Effect focuses it. Typing a detail address directly in the browser is an initial load, so the guard intentionally does not move focus in that case.

Practice and verify focus behavior

Narrow an overly broad focus trigger

Complete the dependency array so the page-heading focus Effect responds to page-level navigation rather than every URL update.

tsx
const { pathname } = useLocation();

useEffect(() => {
  if (previousPathname.current === pathname) {
    return;
  }

  previousPathname.current = pathname;
  headingRef.current?.focus();
}, []);

Verify both focus behaviors

Run both checks in your local app after making the changes.

  1. Open /tickets?status=open&source=guide, Tab to Status, and use the keyboard to choose Closed. Report the active element after the URL and list update.
  2. Open /tickets/1, then click the in-app Improve contrast link. Report the active element after the route changes and identify the pathname-based navigation that triggered it.

In 3–5 short lines, include the dependency used by the Effect and the observed active element after each check.

Write at least 110 characters (0/110).

Step 8 of 8

Verify a Reproducible URL-Driven View

Verify that Issue Desk rebuilds detail and filter views from URL state, including invalid values, refresh, browser history, unrelated parameters, and keyboard focus.

Start with direct navigation

Verify from the address bar

A reproducible view can be rebuilt without any component memory: enter an address directly in a new tab, then refresh it. The route parameter selects a detail record; the status search parameter selects a list view.

Use your running local Vite app for this final check. Start by typing each address into a fresh tab rather than navigating there from an already-open view. This confirms that the URL state—not a previous interaction—determines what renders.

URL state can rebuild the view

Diagram showing a ticket detail and a filtered ticket list being reconstructed from browser addresses, refresh, and history navigation.

The address determines the selected detail or filtered list. Refresh and browser history provide an address for the components to read again.

Use a focused verification plan

Acceptance matrix

Run this compact acceptance matrix after your repair. source=guide is intentionally unrelated to filtering: it must remain in the address but must not change the displayed tickets.

Expected behavior for direct addresses and filter values.
Address or actionExpected result
`/tickets/2`Detail for Improve contrast
`/tickets/abc`Invalid ticket ID feedback
`/tickets/999`Ticket not found feedback
`/tickets?status=open&source=guide`Open selected; Fix navigation and Update help shown; source retained
`/tickets?status=waiting`All tickets selected as fallback; address is not rewritten just by reading it

History and keyboard checks

For history, begin at /tickets?source=guide, select Open, then select Closed. With push updates, Back restores the earlier selected filter and Forward restores the later one.

For keyboard focus, Tab to Status and change its value with the keyboard. After the URL update, the same mounted select should still have focus. This is unlike a detail navigation, where heading focus is intentional.

Apply the rules in code

Integrated repair

Complete the missing conditions and URLSearchParams method names in this repair fragment. It preserves the three detail outcomes and changes only the URL-owned status key.

Repair URL-driven outcomes

Complete the repair fragment.

tsx
function TicketDetail() {
  const { ticketId } = useParams();
  const id = parseTicketId(ticketId);

  if () {
    return <h1>Invalid ticket ID</h1>;
  }

  const ticket = tickets.find((item) => item.id === id);

  if () {
    return <h1>Ticket not found</h1>;
  }

  return <h1>{ticket.title}</h1>;
}

function TicketList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const status = parseStatus(searchParams.get('status'));

  function handleStatusChange(event: ChangeEvent<HTMLSelectElement>) {
    const next = new URLSearchParams(searchParams);
    const nextStatus = parseStatus(event.currentTarget.value);

    if (nextStatus === 'all') {
      next.('status');
    } else {
      next.('status', nextStatus);
    }

    setSearchParams(next);
  }

  return <select aria-label="Status" value={status} onChange={handleStatusChange} />;
}

Confirm the behavior locally

Report your local verification

Run the repaired app and record what you actually observe. Include all of the following:

  • What /tickets/abc and /tickets/999 display.
  • After directly opening and refreshing /tickets?status=open&source=guide, the selected Status value, displayed tickets, and whether source=guide remains.
  • What Back and then Forward restore after deliberate status selections.
  • Whether focus remains on Status when you change it with the keyboard.

Write at least 250 characters (0/250).

Finish

Summary

Final review

You verified one coherent URL-driven workflow without adding parallel component state.

  • ticketId and status are URL-owned state because they identify a reproducible view.
  • Parsed identifiers, parsed filters, matching tickets, and displayed feedback are derived from the current URL and local records.
  • A malformed identifier and a valid-but-missing identifier require different feedback.
  • Cloning search parameters preserves unrelated values such as source=guide during a filter update.
  • Refresh, Back, and Forward work because the components read the restored URL.
  • Keyboard focus is transient interaction state; keeping the select mounted preserves it during a filter-only update.

Step complete

Congratulations! You completed: Represent Detail Views and Filters in the URL

You verified that Issue Desk details and filters can be reconstructed from the URL across direct navigation, refresh, history, and keyboard interaction.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate