diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx
index 8d53714..c32cad4 100644
--- a/apps/frontend/src/app/expenses/page.tsx
+++ b/apps/frontend/src/app/expenses/page.tsx
@@ -1,5 +1,6 @@
'use client';
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, Suspense } from 'react';
+import { useQueryParams } from '@/hooks/useQueryParams';
import NavBar from '../components/Navbar';
import Header from '../components/Header';
import Pagination from '../components/Pagination';
@@ -48,18 +49,36 @@ export const EXPENSE_CATEGORIES = [
];
export default function ExpensePage() {
+ return (
+
+
+
+ );
+}
+
+function ExpensePageContent() {
// Data
const [expenditures, setExpenditures] = useState([]);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- // Search & Filters
- const [query, setQuery] = useState('');
- const [selectedMonths, setSelectedMonths] = useState([]);
- const [selectedTypes, setSelectedTypes] = useState([]);
- const [selectedProjects, setSelectedProjects] = useState([]);
- const [sortOption, setSortOption] = useState('');
+ // Search & Filters (synced to URL query params)
+ const [filters, setFilter] = useQueryParams({
+ q: '',
+ months: [] as string[],
+ types: [] as string[],
+ projects: [] as string[],
+ sort: '',
+ page: '',
+ });
+
+ const query = filters.q;
+ const selectedMonths = filters.months;
+ const selectedTypes = filters.types;
+ const selectedProjects = filters.projects;
+ const sortOption = filters.sort;
+ const currentPage = parseInt(filters.page, 10) || 1;
// Dropdown visibility
const [showMonthFilter, setShowMonthFilter] = useState(false);
@@ -67,9 +86,6 @@ export default function ExpensePage() {
const [showProjectFilter, setShowProjectFilter] = useState(false);
const [showSortBy, setShowSortBy] = useState(false);
- // Pagination
- const [currentPage, setCurrentPage] = useState(1);
-
// Modal
const [showNewExpense, setShowNewExpense] = useState(false);
@@ -146,10 +162,6 @@ export default function ExpensePage() {
currentPage * ROWS_PER_PAGE,
);
- // Reset page when filters change
- useEffect(() => {
- setCurrentPage(1);
- }, [query, selectedMonths, selectedTypes, selectedProjects, sortOption]);
// Modal success handler
async function handleExpenseAdded() {
@@ -182,7 +194,7 @@ export default function ExpensePage() {
placeholder="Search ..."
variant="outline"
value={query}
- onChange={(e) => setQuery(e.target.value)}
+ onChange={(e) => setFilter({ q: e.target.value, page: '' })}
/>
@@ -210,7 +222,7 @@ export default function ExpensePage() {
multiSelect={true}
hideTrigger={true}
value={selectedProjects}
- onChange={(val) => setSelectedProjects(Array.isArray(val) ? val : [val])}
+ onChange={(val) => setFilter({ projects: Array.isArray(val) ? val : [val], page: '' })}
/>
)}
@@ -240,7 +252,7 @@ export default function ExpensePage() {
multiSelect={true}
hideTrigger={true}
value={selectedMonths}
- onChange={(val) => setSelectedMonths(Array.isArray(val) ? val : [val])}
+ onChange={(val) => setFilter({ months: Array.isArray(val) ? val : [val], page: '' })}
/>
)}
@@ -270,7 +282,7 @@ export default function ExpensePage() {
multiSelect={true}
hideTrigger={true}
value={selectedTypes}
- onChange={(val) => setSelectedTypes(Array.isArray(val) ? val : [val])}
+ onChange={(val) => setFilter({ types: Array.isArray(val) ? val : [val], page: '' })}
/>
)}
@@ -300,7 +312,7 @@ export default function ExpensePage() {
multiSelect={false}
hideTrigger={true}
value={sortOption}
- onChange={(val) => setSortOption(val as string)}
+ onChange={(val) => setFilter({ sort: val as string, page: '' })}
/>
)}
@@ -379,7 +391,7 @@ export default function ExpensePage() {
setFilter({ page: String(p) })}
/>
)}
diff --git a/apps/frontend/src/hooks/useQueryParams.ts b/apps/frontend/src/hooks/useQueryParams.ts
new file mode 100644
index 0000000..ddf55ed
--- /dev/null
+++ b/apps/frontend/src/hooks/useQueryParams.ts
@@ -0,0 +1,62 @@
+'use client';
+import { useCallback, useMemo } from 'react';
+import { useRouter, useSearchParams, usePathname } from 'next/navigation';
+
+type ParamValue = string | string[];
+
+/**
+ * Syncs a flat filter/state object to URL query params.
+ *
+ * - String values map to plain query params (e.g. `?q=foo`)
+ * - String[] values map to comma-separated params (e.g. `?months=Jan,Feb`)
+ * - Empty strings and empty arrays are omitted from the URL
+ * - Uses router.replace so filter changes don't add browser history entries
+ *
+ * Usage:
+ * const [params, setParams] = useQueryParams({ q: '', tags: [] as string[] });
+ * setParams({ q: 'hello', tags: ['a', 'b'] });
+ */
+export function useQueryParams>(
+ defaults: T,
+): [T, (updates: Partial) => void] {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const values = useMemo(() => {
+ const result = { ...defaults } as T;
+ for (const key in defaults) {
+ const raw = searchParams.get(key);
+ if (raw === null) continue;
+ if (Array.isArray(defaults[key])) {
+ (result as Record)[key] = raw ? raw.split(',') : [];
+ } else {
+ (result as Record)[key] = raw;
+ }
+ }
+ return result;
+ // searchParams identity changes when URL changes, which is the correct dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams]);
+
+ const setParams = useCallback(
+ (updates: Partial) => {
+ const next = new URLSearchParams(searchParams.toString());
+ for (const key in updates) {
+ const val = updates[key];
+ if (val === undefined) continue;
+ if (Array.isArray(val)) {
+ if (val.length === 0) next.delete(key);
+ else next.set(key, val.join(','));
+ } else {
+ if (!val) next.delete(key);
+ else next.set(key, val as string);
+ }
+ }
+ router.replace(`${pathname}?${next.toString()}`);
+ },
+ [router, pathname, searchParams],
+ );
+
+ return [values, setParams];
+}
diff --git a/apps/frontend/test/hooks/useQueryParams.test.ts b/apps/frontend/test/hooks/useQueryParams.test.ts
new file mode 100644
index 0000000..b38623a
--- /dev/null
+++ b/apps/frontend/test/hooks/useQueryParams.test.ts
@@ -0,0 +1,127 @@
+import { renderHook, act } from '@testing-library/react';
+import { useSearchParams, useRouter, usePathname } from 'next/navigation';
+import { useQueryParams } from '@/hooks/useQueryParams';
+
+// next/navigation is globally mocked in jest.setup.ts
+const mockReplace = jest.fn();
+const mockSearchParams = (init?: string) =>
+ jest.mocked(useSearchParams).mockReturnValue(new URLSearchParams(init) as ReturnType);
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(useRouter).mockReturnValue({
+ replace: mockReplace,
+ push: jest.fn(),
+ prefetch: jest.fn(),
+ back: jest.fn(),
+ forward: jest.fn(),
+ refresh: jest.fn(),
+ });
+ jest.mocked(usePathname).mockReturnValue('/expenses');
+ mockSearchParams();
+});
+
+const defaults = {
+ q: '',
+ months: [] as string[],
+ sort: '',
+ page: '',
+};
+
+describe('useQueryParams — initial values', () => {
+ test('returns defaults when URL has no params', () => {
+ const { result } = renderHook(() => useQueryParams(defaults));
+ expect(result.current[0]).toEqual(defaults);
+ });
+
+ test('reads a string param from the URL', () => {
+ mockSearchParams('q=travel');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ expect(result.current[0].q).toBe('travel');
+ });
+
+ test('reads an array param from a comma-separated URL value', () => {
+ mockSearchParams('months=January%2CFebruary');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ expect(result.current[0].months).toEqual(['January', 'February']);
+ });
+
+ test('returns empty array for an array param with empty string value', () => {
+ mockSearchParams('months=');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ expect(result.current[0].months).toEqual([]);
+ });
+
+ test('falls back to default for unrecognised keys', () => {
+ mockSearchParams('unknown=foo');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ expect(result.current[0]).toEqual(defaults);
+ });
+});
+
+describe('useQueryParams — setParams', () => {
+ test('calls router.replace with updated string param', () => {
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ q: 'food' }); });
+ expect(mockReplace).toHaveBeenCalledWith('/expenses?q=food');
+ });
+
+ test('calls router.replace with comma-separated array param', () => {
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ months: ['January', 'March'] }); });
+ expect(mockReplace).toHaveBeenCalledWith('/expenses?months=January%2CMarch');
+ });
+
+ test('removes param from URL when string is set to empty', () => {
+ mockSearchParams('q=food');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ q: '' }); });
+ expect(mockReplace).toHaveBeenCalledWith('/expenses?');
+ });
+
+ test('removes param from URL when array is set to empty', () => {
+ mockSearchParams('months=January');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ months: [] }); });
+ expect(mockReplace).toHaveBeenCalledWith('/expenses?');
+ });
+
+ test('preserves existing params when updating one key', () => {
+ mockSearchParams('q=food&sort=Amount');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ q: 'travel' }); });
+ const url = mockReplace.mock.calls[0][0] as string;
+ const params = new URLSearchParams(url.split('?')[1]);
+ expect(params.get('q')).toBe('travel');
+ expect(params.get('sort')).toBe('Amount');
+ });
+
+ test('handles multiple updates in a single call', () => {
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ q: 'food', months: ['June'], page: '' }); });
+ const url = mockReplace.mock.calls[0][0] as string;
+ const params = new URLSearchParams(url.split('?')[1]);
+ expect(params.get('q')).toBe('food');
+ expect(params.get('months')).toBe('June');
+ expect(params.has('page')).toBe(false);
+ });
+
+ test('page reset pattern: clears page when filter changes', () => {
+ mockSearchParams('q=old&page=3');
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ q: 'new', page: '' }); });
+ const url = mockReplace.mock.calls[0][0] as string;
+ const params = new URLSearchParams(url.split('?')[1]);
+ expect(params.get('q')).toBe('new');
+ expect(params.has('page')).toBe(false);
+ });
+
+ test('uses router.replace not push (no history entry)', () => {
+ const mockPush = jest.fn();
+ jest.mocked(useRouter).mockReturnValue({ replace: mockReplace, push: mockPush, prefetch: jest.fn(), back: jest.fn(), forward: jest.fn(), refresh: jest.fn() });
+ const { result } = renderHook(() => useQueryParams(defaults));
+ act(() => { result.current[1]({ q: 'test' }); });
+ expect(mockReplace).toHaveBeenCalled();
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+});