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

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card";
@@ -10,7 +11,6 @@ import { listSongs } from "~/lib/api";
import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites";
import { cn } from "~/lib/utils";
import type { SongSummary } from "~/lib/types";
export function meta() {
return [
@@ -22,12 +22,9 @@ export function meta() {
export default function Home() {
const { isAuthenticated } = useAuth();
const { isFavorite } = useFavorites();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
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 sort = searchParams.get("sort") ?? "date";
@@ -36,17 +33,10 @@ export default function Home() {
const [inputValue, setInputValue] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setLoading(true);
setError(false);
listSongs(q, sort, order)
.then((data) => {
setSongs(data);
setLocalSongs([]);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [q, sort, order]);
const { data: songs = [], isLoading, isError, refetch } = useQuery({
queryKey: ["songs", q, sort, order],
queryFn: () => listSongs(q, sort, order),
});
const handleSearch = useCallback(
(value: string) => {
@@ -70,8 +60,7 @@ export default function Home() {
[],
);
const merged = [...songs, ...localSongs];
const allSongs = merged.toSorted((a, b) => {
const allSongs = songs.toSorted((a, b) => {
const af = isFavorite(a.id) ? 0 : 1;
const bf = isFavorite(b.id) ? 0 : 1;
return af - bf;
@@ -136,45 +125,38 @@ export default function Home() {
))}
</div>
{error && (
{isError && (
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
<p className="text-sm text-muted-foreground">
Couldn't load your songs. Is the API running?
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setError(false);
setLoading(true);
listSongs(q, sort, order)
.then(setSongs)
.catch(() => setError(true))
.finally(() => setLoading(false));
}}
>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{loading && !error && (
{isLoading && !isError && (
<div className="flex justify-center pt-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && !error && allSongs.length === 0 && (
{!isLoading && !isError && allSongs.length === 0 && (
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
{q
? "No songs match your search."
: "No songs yet. Tap Add to get started."}
</p>
)}
{!loading && (
{!isLoading && (
<div className="grid grid-cols-2 gap-3">
{allSongs.map((song) => (
<SongCard key={song.id} song={song} isFavorite={isFavorite(song.id)} />
<SongCard
key={song.id}
song={song}
isFavorite={isFavorite(song.id)}
/>
))}
{isAuthenticated && (
<Card
@@ -193,7 +175,9 @@ export default function Home() {
<AddSongSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])}
onSongAdded={() =>
queryClient.invalidateQueries({ queryKey: ["songs"] })
}
/>
</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 { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import { TransposeBar } from "~/components/transpose-bar";
@@ -17,7 +18,7 @@ import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites";
import { useFullscreen } from "~/hooks/use-fullscreen";
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";
@@ -41,6 +42,19 @@ function initInstrument(): Instrument {
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() {
return [{ title: "PocketChords" }];
}
@@ -53,25 +67,8 @@ export default function SongDetail() {
useWakeLock();
const [baseSong, setBaseSong] = useState<Song | null>(null);
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
const [loading, setLoading] = useState(true);
const [offset, setOffset] = useState(() => initOffset(id));
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 [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -79,21 +76,16 @@ export default function SongDetail() {
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLoading(true);
getSong(id, { transpose: initOffset })
.then((s) => {
setBaseSong(s);
setDisplayedSong(s);
})
.finally(() => setLoading(false));
}, [id]); // eslint-disable-line
const { data: baseSong, isLoading } = useQuery({
queryKey: ["song", id],
queryFn: () => getSong(id),
});
useEffect(() => {
getSong(id, { applyCapo, transpose: offset }).then((s) => {
if (s) setDisplayedSong(s);
});
}, [id, applyCapo, offset]);
const { data: displayedSong } = useQuery({
queryKey: ["song", id, "view", offset, applyCapo],
queryFn: () => getSong(id, { applyCapo, transpose: offset }),
enabled: !!baseSong,
});
function handleOffsetChange(newOffset: number) {
setOffset(newOffset);
@@ -129,7 +121,7 @@ export default function SongDetail() {
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (loading) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<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 (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground text-sm">
@@ -153,18 +147,15 @@ export default function SongDetail() {
);
}
const uniqueChords = extractUniqueChords(displayedSong.sections);
const sectionItems = displayedSong.sections.map((s, i) => ({
const uniqueChords = extractUniqueChords(song.sections);
const sectionItems = song.sections.map((s, i) => ({
label: s.label,
index: i,
}));
const handleChordClick = (chord: string) => setActiveChord(chord);
function handleUpdated(summary: SongSummary) {
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev));
setDisplayedSong((prev) =>
prev ? { ...prev, meta: summary.meta } : prev,
);
// meta-only update; react-query will refetch on next focus
}
return (
@@ -194,7 +185,7 @@ export default function SongDetail() {
>
<div className="max-w-lg mx-auto lg:max-w-none">
<ChordChart
sections={displayedSong.sections}
sections={song.sections}
fontSize={fontSize}
onChordClick={handleChordClick}
/>

27
app/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -4045,6 +4046,32 @@
"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": {
"version": "4.9.1",
"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",
"@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",