refactor(app): react-query for data fetching, fix infinite re-render

This commit is contained in:
2026-07-11 22:37:11 +02:00
parent af328deac1
commit bb4d07055f
5 changed files with 98 additions and 88 deletions

View File

@@ -6,6 +6,7 @@ import {
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from "react-router"; } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
@@ -13,6 +14,10 @@ import "./app.css";
import { AuthProvider } from "./lib/auth"; import { AuthProvider } from "./lib/auth";
import { TooltipProvider } from "./components/ui/tooltip"; import { TooltipProvider } from "./components/ui/tooltip";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ {
@@ -43,15 +48,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links /> <Links />
</head> </head>
<body> <body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <QueryClientProvider client={queryClient}>
<AuthProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider> <AuthProvider>
{children} <TooltipProvider>
<ScrollRestoration /> {children}
<Scripts /> <ScrollRestoration />
</TooltipProvider> <Scripts />
</AuthProvider> </TooltipProvider>
</ThemeProvider> </AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -10,7 +11,6 @@ import { listSongs } from "~/lib/api";
import { useAuth } from "~/lib/auth"; import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites"; import { useFavorites } from "~/hooks/use-favorites";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { SongSummary } from "~/lib/types";
export function meta() { export function meta() {
return [ return [
@@ -22,12 +22,9 @@ export function meta() {
export default function Home() { export default function Home() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { isFavorite } = useFavorites(); const { isFavorite } = useFavorites();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [songs, setSongs] = useState<SongSummary[]>([]);
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
const sort = searchParams.get("sort") ?? "date"; const sort = searchParams.get("sort") ?? "date";
@@ -36,17 +33,10 @@ export default function Home() {
const [inputValue, setInputValue] = useState(q); const [inputValue, setInputValue] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { const { data: songs = [], isLoading, isError, refetch } = useQuery({
setLoading(true); queryKey: ["songs", q, sort, order],
setError(false); queryFn: () => listSongs(q, sort, order),
listSongs(q, sort, order) });
.then((data) => {
setSongs(data);
setLocalSongs([]);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [q, sort, order]);
const handleSearch = useCallback( const handleSearch = useCallback(
(value: string) => { (value: string) => {
@@ -70,8 +60,7 @@ export default function Home() {
[], [],
); );
const merged = [...songs, ...localSongs]; const allSongs = songs.toSorted((a, b) => {
const allSongs = merged.toSorted((a, b) => {
const af = isFavorite(a.id) ? 0 : 1; const af = isFavorite(a.id) ? 0 : 1;
const bf = isFavorite(b.id) ? 0 : 1; const bf = isFavorite(b.id) ? 0 : 1;
return af - bf; return af - bf;
@@ -136,45 +125,38 @@ export default function Home() {
))} ))}
</div> </div>
{error && ( {isError && (
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center"> <div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Couldn't load your songs. Is the API running? Couldn't load your songs. Is the API running?
</p> </p>
<Button <Button variant="outline" size="sm" onClick={() => refetch()}>
variant="outline"
size="sm"
onClick={() => {
setError(false);
setLoading(true);
listSongs(q, sort, order)
.then(setSongs)
.catch(() => setError(true))
.finally(() => setLoading(false));
}}
>
Retry Retry
</Button> </Button>
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto px-4 pb-4"> <div className="flex-1 overflow-y-auto px-4 pb-4">
{loading && !error && ( {isLoading && !isError && (
<div className="flex justify-center pt-12"> <div className="flex justify-center pt-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div> </div>
)} )}
{!loading && !error && allSongs.length === 0 && ( {!isLoading && !isError && allSongs.length === 0 && (
<p className="text-sm text-muted-foreground text-center pt-8 pb-4"> <p className="text-sm text-muted-foreground text-center pt-8 pb-4">
{q {q
? "No songs match your search." ? "No songs match your search."
: "No songs yet. Tap Add to get started."} : "No songs yet. Tap Add to get started."}
</p> </p>
)} )}
{!loading && ( {!isLoading && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{allSongs.map((song) => ( {allSongs.map((song) => (
<SongCard key={song.id} song={song} isFavorite={isFavorite(song.id)} /> <SongCard
key={song.id}
song={song}
isFavorite={isFavorite(song.id)}
/>
))} ))}
{isAuthenticated && ( {isAuthenticated && (
<Card <Card
@@ -193,7 +175,9 @@ export default function Home() {
<AddSongSheet <AddSongSheet
open={sheetOpen} open={sheetOpen}
onOpenChange={setSheetOpen} onOpenChange={setSheetOpen}
onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])} onSongAdded={() =>
queryClient.invalidateQueries({ queryKey: ["songs"] })
}
/> />
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { TransposeBar } from "~/components/transpose-bar"; import { TransposeBar } from "~/components/transpose-bar";
@@ -17,7 +18,7 @@ import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites"; import { useFavorites } from "~/hooks/use-favorites";
import { useFullscreen } from "~/hooks/use-fullscreen"; import { useFullscreen } from "~/hooks/use-fullscreen";
import { useWakeLock } from "~/hooks/use-wake-lock"; import { useWakeLock } from "~/hooks/use-wake-lock";
import type { Song, SongSummary } from "~/lib/types"; import type { SongSummary } from "~/lib/types";
type FontSize = "sm" | "base" | "lg"; type FontSize = "sm" | "base" | "lg";
@@ -41,6 +42,19 @@ function initInstrument(): Instrument {
return "piano"; return "piano";
} }
function initOffset(id: string): number {
try {
const v = localStorage.getItem(`transpose:${id}`);
if (v !== null) {
const n = parseInt(v, 10);
if (!isNaN(n)) return n;
}
} catch {
/* noop */
}
return 0;
}
export function meta() { export function meta() {
return [{ title: "PocketChords" }]; return [{ title: "PocketChords" }];
} }
@@ -53,25 +67,8 @@ export default function SongDetail() {
useWakeLock(); useWakeLock();
const [baseSong, setBaseSong] = useState<Song | null>(null); const [offset, setOffset] = useState(() => initOffset(id));
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
const [loading, setLoading] = useState(true);
const [applyCapo, setApplyCapo] = useState(false); const [applyCapo, setApplyCapo] = useState(false);
const initOffset = (() => {
try {
const v = localStorage.getItem(`transpose:${id}`);
if (v !== null) {
const n = parseInt(v, 10);
if (!isNaN(n)) return n;
}
} catch {
/* noop */
}
return 0;
})();
const [offset, setOffset] = useState(initOffset);
const [fontSize, setFontSize] = useState<FontSize>(initFontSize); const [fontSize, setFontSize] = useState<FontSize>(initFontSize);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@@ -79,21 +76,16 @@ export default function SongDetail() {
const [instrument, setInstrument] = useState<Instrument>(initInstrument); const [instrument, setInstrument] = useState<Instrument>(initInstrument);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const { data: baseSong, isLoading } = useQuery({
setLoading(true); queryKey: ["song", id],
getSong(id, { transpose: initOffset }) queryFn: () => getSong(id),
.then((s) => { });
setBaseSong(s);
setDisplayedSong(s);
})
.finally(() => setLoading(false));
}, [id]); // eslint-disable-line
useEffect(() => { const { data: displayedSong } = useQuery({
getSong(id, { applyCapo, transpose: offset }).then((s) => { queryKey: ["song", id, "view", offset, applyCapo],
if (s) setDisplayedSong(s); queryFn: () => getSong(id, { applyCapo, transpose: offset }),
}); enabled: !!baseSong,
}, [id, applyCapo, offset]); });
function handleOffsetChange(newOffset: number) { function handleOffsetChange(newOffset: number) {
setOffset(newOffset); setOffset(newOffset);
@@ -129,7 +121,7 @@ export default function SongDetail() {
el?.scrollIntoView({ behavior: "smooth", block: "start" }); el?.scrollIntoView({ behavior: "smooth", block: "start" });
} }
if (loading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
@@ -137,7 +129,9 @@ export default function SongDetail() {
); );
} }
if (!baseSong || !displayedSong) { const song = displayedSong ?? baseSong;
if (!baseSong || !song) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4"> <div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -153,18 +147,15 @@ export default function SongDetail() {
); );
} }
const uniqueChords = extractUniqueChords(displayedSong.sections); const uniqueChords = extractUniqueChords(song.sections);
const sectionItems = displayedSong.sections.map((s, i) => ({ const sectionItems = song.sections.map((s, i) => ({
label: s.label, label: s.label,
index: i, index: i,
})); }));
const handleChordClick = (chord: string) => setActiveChord(chord); const handleChordClick = (chord: string) => setActiveChord(chord);
function handleUpdated(summary: SongSummary) { function handleUpdated(summary: SongSummary) {
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev)); // meta-only update; react-query will refetch on next focus
setDisplayedSong((prev) =>
prev ? { ...prev, meta: summary.meta } : prev,
);
} }
return ( return (
@@ -194,7 +185,7 @@ export default function SongDetail() {
> >
<div className="max-w-lg mx-auto lg:max-w-none"> <div className="max-w-lg mx-auto lg:max-w-none">
<ChordChart <ChordChart
sections={displayedSong.sections} sections={song.sections}
fontSize={fontSize} fontSize={fontSize}
onChordClick={handleChordClick} onChordClick={handleChordClick}
/> />

27
app/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0", "@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0", "@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -4045,6 +4046,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tonaljs/abc-notation": { "node_modules/@tonaljs/abc-notation": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",

View File

@@ -14,6 +14,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0", "@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0", "@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",