From 3695ee41e3b502e32dfaa15ca24d1a22d02cef8c Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:09:27 -0700 Subject: [PATCH] perf(races): keep distance/year/search filters responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /races page renders up to ~1,500 race cards. Clicking a distance filter updated both the active-button highlight and the full grid in a single synchronous render, so React couldn't paint the new button state until the entire list re-reconciled — the button appeared frozen for seconds, as reported in Reddit feedback. Keep the filter selections urgent so buttons/inputs respond instantly, and derive the expensive filtered grid from useDeferredValue copies so the heavy re-render happens in a non-blocking background pass. Extract the grid into a memoized component (so toggling the pending indicator doesn't re-render every card) and surface an "Updating…" spinner with a dimmed list while the deferred render catches up. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01W7KxWfJiEzf58PeEWo5FRw --- app/src/app/races/race-list.tsx | 127 ++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/app/src/app/races/race-list.tsx b/app/src/app/races/race-list.tsx index 447a2c6..ba4bb6a 100644 --- a/app/src/app/races/race-list.tsx +++ b/app/src/app/races/race-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { memo, useDeferredValue, useMemo, useState } from "react"; import Link from "next/link"; import type { RaceInfo } from "@/lib/types"; import { getCountryFlag } from "@/lib/flags"; @@ -28,14 +28,81 @@ function getYear(date: string): string { return date.slice(0, 4); } +/** + * Renders the grid of race cards. Memoized so that urgent re-renders (e.g. + * toggling the "updating" indicator the instant a filter button is clicked) + * don't re-render all ~1,500 cards — only a change to the filtered list does. + */ +const RaceGrid = memo(function RaceGrid({ races }: { races: RaceInfo[] }) { + return ( +
+ {races.map((race) => { + const location = getRaceLocation(race); + const flag = location ? getCountryFlag(location) : ""; + + return ( + +
+

+ {cleanRaceName(race.name)} +

+ + {getDistanceLabel(race.slug)} + +
+ +

+ {flag && {flag}} + {location} +

+ +
+ {formatDate(race.date)} + · + {race.finishers.toLocaleString()} finishers +
+ + ); + })} +
+ ); +}); + export default function RaceList({ races }: { races: RaceInfo[] }) { const [distance, setDistance] = useState("All"); const [year, setYear] = useState("All"); const [query, setQuery] = useState(""); + // Filter selections stay urgent so buttons/inputs update the instant they're + // clicked. The expensive list is derived from *deferred* copies, so React + // paints the new button state first, then re-renders the ~1,500-card grid in + // a non-blocking background pass instead of freezing the click for seconds. + const deferredDistance = useDeferredValue(distance); + const deferredYear = useDeferredValue(year); + const deferredQuery = useDeferredValue(query); + const years = [...new Set(races.map((r) => getYear(r.date)))].sort().reverse(); - const filtered = filterRaces(races, { distance, year, query }); + const filtered = useMemo( + () => + filterRaces(races, { + distance: deferredDistance, + year: deferredYear, + query: deferredQuery, + }), + [races, deferredDistance, deferredYear, deferredQuery], + ); + + // True while the displayed list is stale — i.e. a filter changed but the + // deferred grid re-render hasn't caught up yet. Drives the loading indicator. + const isPending = + distance !== deferredDistance || + year !== deferredYear || + query !== deferredQuery; const btnClass = (active: boolean) => active @@ -89,8 +156,19 @@ export default function RaceList({ races }: { races: RaceInfo[] }) { -

- Showing {filtered.length} of {races.length} races +

+ + Showing {filtered.length} of {races.length} races + + {isPending && ( + + + )}

{filtered.length === 0 && ( @@ -109,39 +187,14 @@ export default function RaceList({ races }: { races: RaceInfo[] }) { )} -
- {filtered.map((race) => { - const location = getRaceLocation(race); - const flag = location ? getCountryFlag(location) : ""; - - return ( - -
-

- {cleanRaceName(race.name)} -

- - {getDistanceLabel(race.slug)} - -
- -

- {flag && {flag}} - {location} -

- -
- {formatDate(race.date)} - · - {race.finishers.toLocaleString()} finishers -
- - ); - })} +
+
);