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 && (
+
+
+ Updating…
+
+ )}
{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
-
-
- );
- })}
+
+
>
);