From 6bc4cac8c7b5caee61d4b644ace0c12ffdc1bf68 Mon Sep 17 00:00:00 2001
From: abdulqoyumadegoke
Date: Wed, 29 Jul 2026 22:52:18 +0100
Subject: [PATCH] refactor(frontend): extract useAsyncPageData hook for shared
page state scaffolding
---
.../src/app/activity/activity-content.tsx | 24 +++--
.../src/app/incoming/incoming-content.tsx | 28 +++---
frontend/src/hooks/useAsyncPageData.test.ts | 97 +++++++++++++++++++
frontend/src/hooks/useAsyncPageData.ts | 51 ++++++++++
4 files changed, 179 insertions(+), 21 deletions(-)
create mode 100644 frontend/src/hooks/useAsyncPageData.test.ts
create mode 100644 frontend/src/hooks/useAsyncPageData.ts
diff --git a/frontend/src/app/activity/activity-content.tsx b/frontend/src/app/activity/activity-content.tsx
index 2cd7492c..a202be34 100644
--- a/frontend/src/app/activity/activity-content.tsx
+++ b/frontend/src/app/activity/activity-content.tsx
@@ -10,6 +10,7 @@ import { formatAmount } from "@/utils/amount";
import { downloadCSV } from "@/utils/csvExport";
import { getApiBaseUrl } from "@/lib/api/_shared";
import { logger } from "@/lib/logger";
+import { useAsyncPageData } from "@/hooks/useAsyncPageData";
const PAGE_SIZE = 10;
const API_BASE_URL = getApiBaseUrl();
@@ -31,6 +32,11 @@ export default function ActivityContent() {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
+ const pageState = useAsyncPageData({
+ isLoading: loading && events.length === 0,
+ data: events,
+ });
+
const fetchActivity = useCallback(
async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => {
if (!session?.publicKey) return;
@@ -89,18 +95,18 @@ export default function ActivityContent() {
};
const handleExportCSV = () => {
- const csvData = events.map(event => ({
- 'Stream ID': event.streamId,
- 'Event Type': event.eventType,
- 'Amount': event.amount ? formatAmount(BigInt(event.amount), 7) : '0',
- 'Timestamp': new Date(event.timestamp * 1000).toLocaleString(),
- 'Transaction Hash': event.transactionHash,
- 'Ledger': event.ledgerSequence,
+ const csvData = events.map((event) => ({
+ "Stream ID": event.streamId,
+ "Event Type": event.eventType,
+ Amount: event.amount ? formatAmount(BigInt(event.amount), 7) : "0",
+ Timestamp: new Date(event.timestamp * 1000).toLocaleString(),
+ "Transaction Hash": event.transactionHash,
+ Ledger: event.ledgerSequence,
}));
downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`);
};
- if (status !== "connected") {
+ if (!pageState.isConnected) {
return (
Access Denied
@@ -152,7 +158,7 @@ export default function ActivityContent() {
))}
-
+
{hasMore && (
diff --git a/frontend/src/app/incoming/incoming-content.tsx b/frontend/src/app/incoming/incoming-content.tsx
index a5af7ede..9186c835 100644
--- a/frontend/src/app/incoming/incoming-content.tsx
+++ b/frontend/src/app/incoming/incoming-content.tsx
@@ -16,6 +16,7 @@ import {
useIncomingStreams,
useWithdrawIncomingStream,
} from "@/hooks/useIncomingStreams";
+import { useAsyncPageData } from "@/hooks/useAsyncPageData";
interface TrackerState {
status: TransactionStatus;
@@ -40,12 +41,20 @@ function LoadingCard() {
}
export default function IncomingContent() {
- const { session, status, isHydrated } = useWallet();
+ const { session, status } = useWallet();
const [tracker, setTracker] = React.useState({
status: "idle",
});
const incomingStreamsQuery = useIncomingStreams(session?.publicKey);
+
+ const pageState = useAsyncPageData({
+ isLoading: incomingStreamsQuery.isLoading,
+ isError: incomingStreamsQuery.isError,
+ error: incomingStreamsQuery.error,
+ data: incomingStreamsQuery.data ?? [],
+ });
+
const withdrawMutation = useWithdrawIncomingStream(
session,
session?.publicKey,
@@ -91,9 +100,6 @@ export default function IncomingContent() {
}
};
- const isLoading =
- !isHydrated ||
- (status === "connected" && incomingStreamsQuery.isLoading);
const streams = incomingStreamsQuery.data ?? [];
return (
@@ -124,13 +130,13 @@ export default function IncomingContent() {
- {!isHydrated ? (
+ {!pageState.isHydrated ? (
- ) : status !== "connected" ? (
+ ) : !pageState.isConnected ? (
Connect a wallet to view incoming streams
@@ -140,24 +146,22 @@ export default function IncomingContent() {
where you are the recipient and keep the claimable balance fresh.
- ) : incomingStreamsQuery.isError ? (
+ ) : pageState.isError ? (
We couldn't load your incoming streams
- {incomingStreamsQuery.error instanceof Error
- ? incomingStreamsQuery.error.message
- : "Please try again in a moment."}
+ {pageState.errorMessage || "Please try again in a moment."}
- ) : isLoading ? (
+ ) : pageState.isLoading ? (
- ) : streams.length === 0 ? (
+ ) : pageState.isEmpty ? (
No incoming streams yet
diff --git a/frontend/src/hooks/useAsyncPageData.test.ts b/frontend/src/hooks/useAsyncPageData.test.ts
new file mode 100644
index 00000000..57931f03
--- /dev/null
+++ b/frontend/src/hooks/useAsyncPageData.test.ts
@@ -0,0 +1,97 @@
+import { renderHook } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { useAsyncPageData } from "./useAsyncPageData";
+import { useWallet } from "@/context/wallet-context";
+
+vi.mock("@/context/wallet-context", () => ({
+ useWallet: vi.fn(),
+}));
+
+describe("useAsyncPageData", () => {
+ const mockUseWallet = vi.mocked(useWallet);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns isLoading true when not hydrated", () => {
+ mockUseWallet.mockReturnValue({
+ session: null,
+ status: "disconnected",
+ isHydrated: false,
+ } as any);
+
+ const { result } = renderHook(() => useAsyncPageData({ data: [] }));
+
+ expect(result.current.isHydrated).toBe(false);
+ expect(result.current.isConnected).toBe(false);
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isEmpty).toBe(false);
+ });
+
+ it("returns isConnected false when status is disconnected", () => {
+ mockUseWallet.mockReturnValue({
+ session: null,
+ status: "disconnected",
+ isHydrated: true,
+ } as any);
+
+ const { result } = renderHook(() => useAsyncPageData({ data: [] }));
+
+ expect(result.current.isConnected).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isEmpty).toBe(false);
+ });
+
+ it("handles loading state when wallet is connected", () => {
+ mockUseWallet.mockReturnValue({
+ session: { publicKey: "G123" },
+ status: "connected",
+ isHydrated: true,
+ } as any);
+
+ const { result } = renderHook(() =>
+ useAsyncPageData({ isLoading: true, data: undefined })
+ );
+
+ expect(result.current.isConnected).toBe(true);
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isEmpty).toBe(false);
+ });
+
+ it("evaluates empty state when data is empty array", () => {
+ mockUseWallet.mockReturnValue({
+ session: { publicKey: "G123" },
+ status: "connected",
+ isHydrated: true,
+ } as any);
+
+ const { result } = renderHook(() =>
+ useAsyncPageData({ isLoading: false, data: [] })
+ );
+
+ expect(result.current.isConnected).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isEmpty).toBe(true);
+ });
+
+ it("parses Error message on error state", () => {
+ mockUseWallet.mockReturnValue({
+ session: { publicKey: "G123" },
+ status: "connected",
+ isHydrated: true,
+ } as any);
+
+ const { result } = renderHook(() =>
+ useAsyncPageData({
+ isLoading: false,
+ isError: true,
+ error: new Error("Network failure"),
+ })
+ );
+
+ expect(result.current.isError).toBe(true);
+ expect(result.current.errorMessage).toBe("Network failure");
+ expect(result.current.isEmpty).toBe(false);
+ });
+});
diff --git a/frontend/src/hooks/useAsyncPageData.ts b/frontend/src/hooks/useAsyncPageData.ts
new file mode 100644
index 00000000..8c496a9c
--- /dev/null
+++ b/frontend/src/hooks/useAsyncPageData.ts
@@ -0,0 +1,51 @@
+import { useWallet } from "@/context/wallet-context";
+
+export interface AsyncPageDataOptions {
+ isLoading?: boolean;
+ isError?: boolean;
+ error?: unknown;
+ data?: T[];
+}
+
+export interface AsyncPageDataState {
+ isHydrated: boolean;
+ isConnected: boolean;
+ isLoading: boolean;
+ isError: boolean;
+ errorMessage?: string;
+ isEmpty: boolean;
+}
+
+/**
+ * Shared hook to encapsulate async page state (hydration, wallet connection,
+ * loading state, error parsing, and empty state evaluation).
+ */
+export function useAsyncPageData(options: AsyncPageDataOptions): AsyncPageDataState {
+ const { session, status, isHydrated } = useWallet();
+
+ const isConnected = status === "connected" && Boolean(session?.publicKey);
+ const isLoading = !isHydrated || (isConnected && Boolean(options.isLoading));
+ const isError = isConnected && Boolean(options.isError);
+ const isEmpty =
+ isConnected &&
+ !isLoading &&
+ !isError &&
+ Array.isArray(options.data) &&
+ options.data.length === 0;
+
+ const errorMessage =
+ options.error instanceof Error
+ ? options.error.message
+ : typeof options.error === "string"
+ ? options.error
+ : undefined;
+
+ return {
+ isHydrated,
+ isConnected,
+ isLoading,
+ isError,
+ errorMessage,
+ isEmpty,
+ };
+}