Day 3 of Run Explorer: Why the Search Box and the URL Hold Different Text
Typing in the search box changes nothing until I press Search. Choosing a status rewrites the URL immediately. Back and refresh both restore the table, because the submitted filters live in the address bar.
The short version
Day 2 ended with six runs in a table. Day 3 makes that table shareable. The page now holds 24 synthetic runs and shows six at a time. The text I am still typing stays in the component. The filters I have submitted stay in the URL.
The matching rows are calculated, not stored. selectRuns filters the full list, sorts what remains, and then cuts out one page. Doing those steps in another order hides runs that match.
Two kinds of text
The search box has its own state, called draft. Each keystroke updates draft and nothing else. The address bar stays the same, and the count stays at 24, until I submit the form. Submit calls update({ q: draft, page: 1 }).
The status menu does not have a draft. Choosing Failed calls update immediately, so there is no half-chosen status sitting in the component. The URL gains status=failed, and the page number goes back to 1. A new filter should not leave me stranded on a page that belonged to the old one.
useUrl does not keep a React copy of the filters. It reads window.location.search and passes that string through readFilters. A status the app does not recognize falls back to all. A bad page number falls back to 1.
update writes the query string with history.pushState. That adds a history entry, and it does not notify React by itself. The browser fires popstate for Back and Forward only. So update also dispatches a custom event named run-explorer:url. One subscription listens to both. The function it returns removes both listeners when the subscription ends.
This effect copies the URL back into the box when the URL changes from outside the input:
const [draft, setDraft] = useState(filters.q)
useEffect(() => setDraft(filters.q), [filters.q])That is what makes Back refill the search box. The rows are not stored in state. They are selectRuns(runs, filters), calculated during render.
Filter, then sort, then take a page
selectRuns is a pure function. The same runs and the same filters always produce the same page, and the array handed in is left alone. sort rearranges the array it is called on, so the code copies the filtered list first with [...filtered] and sorts the copy. The fixtures stay in their original order for the next render.
Newest-first compares timestamps. When two timestamps tie, the run id breaks the tie, so the order does not flicker. Page 2 with a page size of 6 starts at index 6, because array indexes start at 0. The person sees page 2. The array sees items 6 through 11.
I checked three predictions before trusting the screen:
- Status
failedmatches 6 of the 24 runs. One run in every four is failed. - Search
TANSTACKmatches 12 runs. The project name is Ask TanStack Query, and the comparison is lowercase on both sides. - Page 2, newest first, with the search cleared, begins at
run-007. Page 1 holdsrun-001throughrun-006.
A page number past the end stays empty. The screen shows that page and a way back. It does not invent rows.
What I broke
1. The missing listener
I removed the popstate listener and its cleanup. I changed a filter, then pressed Back. The address bar changed. The table stayed on the filter I had just left. I put the listener back, and Back moved the table again.
2. Slicing before filtering
I changed selectRuns so it sliced a page before filtering. Searching TANSTACK only looked inside the first six fixtures. Matches later in the list disappeared, and the count was too small. I restored filter, then sort, then slice.
What I set up on Day 3
One commit, feat: keep the run filters in the URL. It adds src/url.ts and replaces the page so the filters come from the address bar.
The tests that already covered selectRuns still pass. They check a bad query string, a sort that must not mutate the fixtures, a page taken after the filter, a case-insensitive search, and an empty high page number.
Frequently asked questions
Why is the search box not stored in the URL while I type?
The URL is the submitted view. A half-typed word is not a view I want to share, restore, or turn into a request. Submit writes the finished text and resets the page to 1.
Why does Back need a listener if the URL already changed?
pushState adds a history entry without telling React. Back and Forward fire popstate. The subscription listens for that, and also for the custom event update fires when it changes the URL itself.
Why filter before taking a page?
The page is a slice of the matches. Slicing first and filtering afterward can produce an empty page while matching runs sit on a later page. The count would describe the slice, not the full list.
Does a refresh forget the filter?
A refresh keeps whatever is in the URL. It forgets the unpublished text in the search box, unless that text was submitted.
Next up
The table still filters the fixtures inside the browser. Next, the page asks another program for one page over HTTP. The same filters go in the query string. A slow response and a failed response become visible, and the mock answers them before a real API exists.
If you want to follow along, the repository is public and every day lands as a commit.
Related: Day 2 of Run Explorer: Why the Table Scrolls Sideways and the Page Does Not
