Skip to content

Commit a81b705

Browse files
jamesdoh0109claude
andcommitted
Refactor to React Query hydration pattern and fix favorites bug
The favorite toggle on listing detail pages was broken because the backend retrieve view did not pass request context to the serializer, so is_favorited always returned false. Additionally, the frontend stored favorite state in a separate query with staleTime: Infinity that could never self-correct. This adopts React Query's hydration pattern (prefetchQuery + dehydrate + HydrationBoundary) for grid and detail pages, replacing the initialData-from-props approach. The favorite toggle now uses optimistic updates on the listing cache entry itself. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c0dde5 commit a81b705

12 files changed

Lines changed: 115 additions & 68 deletions

File tree

backend/market/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def retrieve(self, request, *args, **kwargs):
189189
serializer_class = ListingSerializer
190190
else:
191191
serializer_class = ListingSerializerPublic
192-
serializer = serializer_class(instance)
192+
serializer = serializer_class(instance, context={"request": request})
193193
return Response(serializer.data)
194194

195195

frontend/app/items/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const item = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(item.id), item);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={item.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/page.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getItems } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { ItemFilters } from "@/components/filters/ItemFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function ItemsPage() {
7-
const [items, currentUser] = await Promise.all([getItems({ pageParam: 1 }), getCurrentUser()]);
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("items"),
14+
queryFn: () => getItems({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
20+
getCurrentUser(),
21+
]);
822

923
return (
1024
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1125
<PageHeader title="Browse Items" description="Discover the latest items on sale at Penn" />
1226
<ItemFilters />
13-
<ListingsGrid type="items" listings={items} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="items" currentUser={currentUser} />
29+
</HydrationBoundary>
1430
</div>
1531
);
1632
}

frontend/app/sublets/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const sublet = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(sublet.id), sublet);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={sublet.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/sublets/page.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getSublets } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { SubletFilters } from "@/components/filters/SubletFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function SubletsPage() {
7-
const [sublets, currentUser] = await Promise.all([
8-
getSublets({ pageParam: 1 }),
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("sublets"),
14+
queryFn: () => getSublets({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
920
getCurrentUser(),
1021
]);
1122

1223
return (
1324
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1425
<PageHeader title="Browse Sublets" description="Find your perfect housing solution at Penn" />
1526
<SubletFilters />
16-
<ListingsGrid type="sublets" listings={sublets} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="sublets" currentUser={currentUser} />
29+
</HydrationBoundary>
1730
</div>
1831
);
1932
}

frontend/components/listings/ListingsGrid.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@ import { Spinner } from "@/components/ui/spinner";
55
import { ListingsCard } from "@/components/listings/ListingsCard";
66
import { NoListingsFound } from "@/components/listings/NoListingsFound";
77
import { useListings } from "@/hooks/useListings";
8-
import { Item, PaginatedResponse, Sublet, User } from "@/lib/types";
8+
import { ListingTypes, User } from "@/lib/types";
99

10-
type Props =
11-
| {
12-
type: "items";
13-
listings: PaginatedResponse<Item>;
14-
currentUser: User;
15-
}
16-
| {
17-
type: "sublets";
18-
listings: PaginatedResponse<Sublet>;
19-
currentUser: User;
20-
};
10+
type Props = {
11+
type: ListingTypes;
12+
currentUser: User;
13+
};
2114

22-
export const ListingsGrid = ({ type, listings, currentUser }: Props) => {
23-
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type, listings });
15+
export const ListingsGrid = ({ type, currentUser }: Props) => {
16+
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type });
2417

2518
const totalResults = data?.pages.reduce((acc, page) => acc + page.results.length, 0) || 0;
2619
const isEmpty = totalResults === 0;

frontend/components/listings/detail/ListingDetail.tsx

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

33
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4-
import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions";
4+
import { addToUsersFavorites, deleteFromUsersFavorites, getListing } from "@/lib/actions";
5+
import { queryKeys } from "@/lib/queryKeys";
56
import { Heart, Share } from "lucide-react";
67
import { Item, Sublet } from "@/lib/types";
78
import { ListingActions } from "@/components/listings/detail/ListingActions";
@@ -12,42 +13,40 @@ import { BackButton } from "@/components/listings/detail/BackButton";
1213
import { SubletMap } from "@/components/listings/detail/SubletMap";
1314

1415
interface Props {
15-
listing: Item | Sublet;
16-
initialIsFavorited: boolean;
16+
listingId: number;
1717
}
1818

19-
export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
20-
const listingType = listing.listing_type;
21-
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
22-
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
19+
export const ListingDetail = ({ listingId }: Props) => {
2320
const queryClient = useQueryClient();
24-
const favoritesQuery = useQuery({
25-
queryKey: ["favorite", listing.id],
26-
queryFn: async () => initialIsFavorited,
27-
initialData: initialIsFavorited,
28-
staleTime: Infinity,
21+
const queryKey = queryKeys.listing(listingId);
22+
23+
const { data: listing } = useQuery({
24+
queryKey,
25+
queryFn: () => getListing(String(listingId)),
2926
});
3027

31-
const isFavorited = favoritesQuery.data ?? false;
28+
const isFavorited = listing?.is_favorited ?? false;
3229

3330
const toggleFavoriteMutation = useMutation({
3431
meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update
3532
mutationFn: async (shouldFavorite: boolean) => {
3633
if (shouldFavorite) {
37-
await addToUsersFavorites(listing.id);
34+
await addToUsersFavorites(listingId);
3835
} else {
39-
await deleteFromUsersFavorites(listing.id);
36+
await deleteFromUsersFavorites(listingId);
4037
}
4138
},
4239
onMutate: async (shouldFavorite: boolean) => {
43-
await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] });
44-
const previous = queryClient.getQueryData<boolean>(["favorite", listing.id]);
45-
queryClient.setQueryData(["favorite", listing.id], shouldFavorite);
40+
await queryClient.cancelQueries({ queryKey });
41+
const previous = queryClient.getQueryData<Item | Sublet>(queryKey);
42+
if (previous) {
43+
queryClient.setQueryData(queryKey, { ...previous, is_favorited: shouldFavorite });
44+
}
4645
return { previous };
4746
},
4847
onError: (_error, _shouldFavorite, context) => {
49-
if (context?.previous !== undefined) {
50-
queryClient.setQueryData(["favorite", listing.id], context.previous);
48+
if (context?.previous) {
49+
queryClient.setQueryData(queryKey, context.previous);
5150
}
5251
},
5352
});
@@ -56,10 +55,14 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
5655
toggleFavoriteMutation.mutate(!isFavorited);
5756
};
5857

59-
const subletCoords =
60-
listingType === "sublet" ? listing.additional_data : null;
61-
const hasLocation =
62-
subletCoords?.latitude != null && subletCoords?.longitude != null;
58+
if (!listing) return null;
59+
60+
const listingType = listing.listing_type;
61+
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
62+
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
63+
64+
const subletCoords = listingType === "sublet" ? listing.additional_data : null;
65+
const hasLocation = subletCoords?.latitude != null && subletCoords?.longitude != null;
6366

6467
return (
6568
<div className="mx-auto flex w-full max-w-[96rem] flex-col p-8 px-4 sm:px-12">
@@ -94,13 +97,11 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
9497
<div>
9598
<h2 className="text-lg font-semibold">{"Where you'll be living"}</h2>
9699
<p className="text-sm text-gray-500">
97-
Approximate location shown. The exact location will be shared once you connect with the owner.
100+
Approximate location shown. The exact location will be shared once you connect
101+
with the owner.
98102
</p>
99-
</div>
100-
<SubletMap
101-
latitude={subletCoords.latitude!}
102-
longitude={subletCoords.longitude!}
103-
/>
103+
</div>
104+
<SubletMap latitude={subletCoords.latitude!} longitude={subletCoords.longitude!} />
104105
</div>
105106
)}
106107
<ListingActions

frontend/components/listings/detail/SubletMap.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ interface Props {
88
}
99

1010
const LazyMap = dynamic(
11-
() =>
12-
import("@/components/listings/detail/SubletMapContent").then((m) => m.SubletMapContent),
13-
{ ssr: false },
11+
() => import("@/components/listings/detail/SubletMapContent").then((m) => m.SubletMapContent),
12+
{ ssr: false }
1413
);
1514

1615
export const SubletMap = ({ latitude, longitude }: Props) => {

frontend/hooks/useListings.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { useEffect } from "react";
22
import { useInView } from "react-intersection-observer";
33
import { useInfiniteQuery } from "@tanstack/react-query";
44
import { getItems, getSublets } from "@/lib/actions";
5-
import { ListingTypes, ListingDataMap, PaginatedResponse, Listing } from "@/lib/types";
5+
import { queryKeys } from "@/lib/queryKeys";
6+
import { ListingTypes, PaginatedResponse, Listing } from "@/lib/types";
67
import { useFilters } from "@/providers/FiltersProvider";
78

89
const LISTING_FETCHERS = {
@@ -12,30 +13,22 @@ const LISTING_FETCHERS = {
1213

1314
export type UseListingsParams<T extends ListingTypes> = {
1415
type: T;
15-
listings: PaginatedResponse<ListingDataMap[T]>;
1616
};
1717

18-
export function useListings<T extends ListingTypes>({ type, listings }: UseListingsParams<T>) {
18+
export function useListings<T extends ListingTypes>({ type }: UseListingsParams<T>) {
1919
const { ref, inView } = useInView();
2020
const { debouncedFilters } = useFilters();
2121

2222
const filters = debouncedFilters[type];
23-
const queryKey = [type, "list", filters];
2423

2524
const queryFn = ({ pageParam = 1 }: { pageParam: unknown }) =>
2625
LISTING_FETCHERS[type]({ pageParam, ...filters });
2726

28-
const hasActiveFilters = Object.values(filters).some((value) => {
29-
if (typeof value === "string") return value.trim() !== "";
30-
return value !== undefined;
31-
});
32-
3327
const { data, error, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery<
3428
PaginatedResponse<Listing>
3529
>({
36-
queryKey,
30+
queryKey: queryKeys.listings(type, filters),
3731
queryFn,
38-
initialData: hasActiveFilters ? undefined : { pages: [listings], pageParams: [1] },
3932
placeholderData: (previousData) => previousData,
4033
initialPageParam: 1,
4134
getNextPageParam(lastPage, allPages) {

frontend/lib/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export async function getSublets({
182182
// ------------------------------------------------------------
183183
// single listing (items or sublets)
184184
// ------------------------------------------------------------
185-
async function getListing(id: string) {
185+
export async function getListing(id: string) {
186186
return await serverFetch<Item | Sublet>(`/market/listings/${id}/`);
187187
}
188188

0 commit comments

Comments
 (0)