v0.22.0: React Query Migration

- Added @tanstack/react-query and @tanstack/react-query-devtools
- Created useTracker, useBills, useCategories custom hooks (useQueries.js)
- Migrated TrackerPage from manual useState/useEffect to useQuery
- Added QueryClientProvider with 2min staleTime, 1 retry, refetchOnWindowFocus: false
- Added ReactQueryDevtools for development
- Fixed error handling: useRef pattern prevents duplicate toast notifications
- Replaced load() callback with refetch() from useQuery
- Hudson security audit: 4/5 PASS (1 FAIL fixed: error handling toast duplication)
This commit is contained in:
null 2026-05-10 03:10:43 -05:00
parent 314159d241
commit d67fe6e61d
6 changed files with 130 additions and 27 deletions

View File

@ -9,6 +9,20 @@ import LoginPage from '@/pages/LoginPage';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import PageLoader from '@/components/PageLoader'; import PageLoader from '@/components/PageLoader';
// TanStack Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
// Lazy-loaded components // Lazy-loaded components
const AdminPage = lazy(() => import('@/pages/AdminPage')); const AdminPage = lazy(() => import('@/pages/AdminPage'));
const TrackerPage = lazy(() => import('@/pages/TrackerPage')); const TrackerPage = lazy(() => import('@/pages/TrackerPage'));
@ -75,7 +89,7 @@ export default function App() {
const mainContentId = useId(); const mainContentId = useId();
return ( return (
<> <QueryClientProvider client={queryClient}>
{/* Release notes (only for user role) */} {/* Release notes (only for user role) */}
{user?.role === 'user' && <ReleaseNotesDialog />} {user?.role === 'user' && <ReleaseNotesDialog />}
@ -176,6 +190,7 @@ export default function App() {
</Route> </Route>
</Routes> </Routes>
</main> </main>
</> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
); );
} }

View File

@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/api';
// Custom hook for fetching tracker data
export function useTracker(year, month) {
return useQuery({
queryKey: ['tracker', year, month],
queryFn: () => api.tracker(year, month),
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
});
}
// Custom hook for fetching all bills
export function useBills() {
return useQuery({
queryKey: ['bills'],
queryFn: () => api.allBills(),
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
});
}
// Custom hook for fetching categories
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: () => api.categories(),
staleTime: 1000 * 60 * 60, // 1 hour
cacheTime: 1000 * 60 * 60 * 2, // 2 hours
});
}

View File

@ -1,10 +1,11 @@
export const APP_VERSION = '0.21.1'; export const APP_VERSION = '0.22.0';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.21.1', version: '0.22.0',
date: '2026-05-10', date: '2026-05-10',
highlights: [ highlights: [
{ icon: '💫', title: 'Loading Skeletons', desc: 'Tracker and Bills pages show skeleton placeholders during data loading.' }, { icon: '🔄', title: 'React Query Migration', desc: 'Tracker page now uses TanStack Query for data fetching with caching and auto-refetch.' },
{ icon: '⚡', title: 'Query DevTools', desc: 'React Query DevTools available in development for inspecting query state.' },
], ],
}; };

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { useTracker } from '@/hooks/useQueries';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -1361,26 +1362,13 @@ export default function TrackerPage() {
const now = new Date(); const now = new Date();
const [year, setYear] = useState(now.getFullYear()); const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1); const [month, setMonth] = useState(now.getMonth() + 1);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
// Edit Bill modal: { bill, categories } when open, null when closed // Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null); const [editBillData, setEditBillData] = useState(null);
// Edit Starting Amounts modal: true when open, false when closed // Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false); const [editStartingOpen, setEditStartingOpen] = useState(false);
const load = useCallback(async () => { // Use React Query for data fetching
setLoading(true); const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
try {
const res = await api.tracker(year, month);
setData(res);
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => { load(); }, [load]);
function navigate(delta) { function navigate(delta) {
setMonth(m => { setMonth(m => {
@ -1409,6 +1397,16 @@ export default function TrackerPage() {
setMonth(n.getMonth() + 1); setMonth(n.getMonth() + 1);
} }
// Handle errors from React Query (use ref to prevent duplicate toasts)
const errorShownRef = useRef(false);
useEffect(() => {
if (isError && !errorShownRef.current) {
toast.error(error?.message || 'Failed to load tracker data');
errorShownRef.current = true;
}
if (!isError) errorShownRef.current = false;
}, [isError, error]);
const rows = data?.rows || []; const rows = data?.rows || [];
const summary = data?.summary || {}; const summary = data?.summary || {};
const first = rows.filter(r => r.bucket === '1st'); const first = rows.filter(r => r.bucket === '1st');
@ -1529,8 +1527,8 @@ export default function TrackerPage() {
</div> </div>
</div> </div>
)} )}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} loading={loading} />} {first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} loading={loading} />} {second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */} {/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && ( {editBillData && (
@ -1538,7 +1536,7 @@ export default function TrackerPage() {
bill={editBillData.bill} bill={editBillData.bill}
categories={editBillData.categories} categories={editBillData.categories}
onClose={() => setEditBillData(null)} onClose={() => setEditBillData(null)}
onSave={() => { setEditBillData(null); load(); }} onSave={() => { setEditBillData(null); refetch(); }}
/> />
)} )}
@ -1548,7 +1546,7 @@ export default function TrackerPage() {
onClose={() => setEditStartingOpen(false)} onClose={() => setEditStartingOpen(false)}
year={year} year={year}
month={month} month={month}
onSave={() => { setEditStartingOpen(false); load(); }} onSave={() => { setEditStartingOpen(false); refetch(); }}
/> />
</div> </div>

59
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.19.0", "version": "0.21.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.19.0", "version": "0.21.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
@ -20,6 +20,8 @@
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@ -2286,6 +2288,59 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/query-core": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
"integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.9.tgz",
"integrity": "sha512-gqiptrTIhbK2PuCaPRHmWXfJG1NGYVFpAr0HqogEqiSBNB5xDz6fmesQt7w4WgMOqOQPnPHJ3ZDMuhDaXvNO8g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
"integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.9.tgz",
"integrity": "sha512-mM3slaVGXJmz+pOLgXdANj75ikgQCyudyl3kmFvm6brI1JyVeY/+IeD17uDHIvZrD8hfoO2sdZ54RFsHdYAuhA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.100.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.100.9",
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.21.1", "version": "0.22.0",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -22,6 +22,8 @@
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",