refactor(app): react-query for data fetching, fix infinite re-render
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
27
app/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user