From 1d9b3cb84e8901ea3f4e23d6b2fef95b37adcedc Mon Sep 17 00:00:00 2001
From: David-patrick-chuks
<161928481+David-patrick-chuks@users.noreply.github.com>
Date: Wed, 24 Jun 2026 05:28:06 +0100
Subject: [PATCH] Refactor data hooks to follow React hook rules.
Split factory-style feature hooks into direct use* query hooks with shared query key helpers, and update guild detail and access check screens to call them at render time.
Closes #15
---
app/access-check.tsx | 8 +--
app/guilds.tsx | 9 ---
app/guilds/[guildId].tsx | 39 +++++-----
src/features/access/useAccessCheck.ts | 31 --------
src/features/access/useAccessCheckResult.ts | 11 +++
src/features/guilds/useGuild.ts | 15 ++++
src/features/guilds/useGuildConfig.ts | 11 +++
src/features/guilds/useGuildRoles.ts | 11 +++
src/features/guilds/useGuilds.ts | 52 --------------
src/features/membership/useMembership.ts | 60 ++++------------
src/features/membership/useUserRoles.ts | 15 ++++
src/lib/queryKeys.ts | 22 ++++++
tests/hooks/queryKeys.test.ts | 42 +++++++++++
tests/hooks/useGuild.hook.test.ts | 80 +++++++++++++++++++++
14 files changed, 240 insertions(+), 166 deletions(-)
delete mode 100644 src/features/access/useAccessCheck.ts
create mode 100644 src/features/access/useAccessCheckResult.ts
create mode 100644 src/features/guilds/useGuild.ts
create mode 100644 src/features/guilds/useGuildConfig.ts
create mode 100644 src/features/guilds/useGuildRoles.ts
delete mode 100644 src/features/guilds/useGuilds.ts
create mode 100644 src/features/membership/useUserRoles.ts
create mode 100644 src/lib/queryKeys.ts
create mode 100644 tests/hooks/queryKeys.test.ts
create mode 100644 tests/hooks/useGuild.hook.test.ts
diff --git a/app/access-check.tsx b/app/access-check.tsx
index ee705c7..54db4d3 100644
--- a/app/access-check.tsx
+++ b/app/access-check.tsx
@@ -5,7 +5,7 @@ import React, { useState } from "react";
// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
import { useWallet } from "../src/features/wallet/useWallet";
// GuildPass Mobile: Import package module dependencies.
-import { useAccessCheck } from "../src/features/access/useAccessCheck";
+import { useAccessCheckResult } from "../src/features/access/useAccessCheckResult";
// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
import { AppHeader } from "../src/components/AppHeader";
// GuildPass Mobile: Import package module dependencies.
@@ -38,13 +38,13 @@ export default function AccessCheck() {
} | null>(null);
// GuildPass Mobile: Local UI-scoped constant or state representation.
- const { checkAccess } = useAccessCheck();
- // GuildPass Mobile: Variable binding and property initialization.
const {
data: result,
isLoading,
error,
- } = checkAccess(checkParams || { walletAddress: "", guildId: "", resourceId: "" });
+ } = useAccessCheckResult(
+ checkParams ?? { walletAddress: "", guildId: "", resourceId: "" },
+ );
// GuildPass Mobile: Local UI-scoped constant or state representation.
const handleCheck = () => {
diff --git a/app/guilds.tsx b/app/guilds.tsx
index 59e2bdd..5813659 100644
--- a/app/guilds.tsx
+++ b/app/guilds.tsx
@@ -2,11 +2,6 @@
import { View, FlatList } from "react-native";
// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
import { useRouter } from "expo-router";
-// GuildPass Mobile: Import package module dependencies.
-import { useWallet } from "../src/features/wallet/useWallet";
-// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
-import { useMembership } from "../src/features/membership/useMembership";
-// GuildPass Mobile: Import package module dependencies.
import { AppHeader } from "../src/components/AppHeader";
// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
import { GuildCard } from "../src/components/GuildCard";
@@ -23,10 +18,6 @@ import React from "react";
export default function Guilds() {
// GuildPass Mobile: Variable binding and property initialization.
const router = useRouter();
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const { walletAddress } = useWallet();
- // GuildPass Mobile: Variable binding and property initialization.
- const { getMembership } = useMembership(walletAddress);
// In a real app, you would fetch all guilds.
// For MVP, we'll show a few example guilds that the user can explore.
diff --git a/app/guilds/[guildId].tsx b/app/guilds/[guildId].tsx
index 239f293..0c4ef91 100644
--- a/app/guilds/[guildId].tsx
+++ b/app/guilds/[guildId].tsx
@@ -5,8 +5,10 @@ import { useLocalSearchParams } from "expo-router";
// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
import { useWallet } from "../../src/features/wallet/useWallet";
// GuildPass Mobile: Import package module dependencies.
-import { useGuilds } from "../../src/features/guilds/useGuilds";
+import { useGuild } from "../../src/features/guilds/useGuild";
// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
+import { useGuildRoles } from "../../src/features/guilds/useGuildRoles";
+// GuildPass Mobile: Import package module dependencies.
import { useMembership } from "../../src/features/membership/useMembership";
// GuildPass Mobile: Import package module dependencies.
import { AppHeader } from "../../src/components/AppHeader";
@@ -23,37 +25,28 @@ import React from "react";
// GuildPass Mobile: Core mobile screen or hook export definition.
export default function GuildDetail() {
- // GuildPass Mobile: Local UI-scoped constant or state representation.
const { guildId } = useLocalSearchParams<{ guildId: string }>();
- // GuildPass Mobile: Variable binding and property initialization.
const { walletAddress } = useWallet();
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const { getGuild, getRoles } = useGuilds();
- // GuildPass Mobile: Variable binding and property initialization.
- const { getMembership } = useMembership(walletAddress);
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const { data: guild, isLoading: guildLoading, error: guildError } = getGuild(guildId);
- // GuildPass Mobile: Variable binding and property initialization.
- const { data: membership, isLoading: memLoading } = getMembership(guildId);
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const { data: roles, isLoading: rolesLoading } = getRoles(guildId);
+ const {
+ data: guild,
+ isLoading: guildLoading,
+ error: guildError,
+ } = useGuild(guildId ?? "");
+ const { data: membership, isLoading: memLoading } = useMembership(
+ walletAddress,
+ guildId ?? "",
+ );
+ const { data: roles, isLoading: rolesLoading } = useGuildRoles(guildId ?? "");
- // GuildPass Mobile: Validate screen variables or params before routing.
if (guildLoading || memLoading || rolesLoading) {
- // GuildPass Mobile: Return evaluated JSX layout or callback response.
return ;
- // GuildPass Mobile: Exit functional execution container scope block.
}
- // GuildPass Mobile: Evaluate branch condition check for UI guards.
if (guildError || !guild) {
- // GuildPass Mobile: Terminate block execution context and send back value.
return ;
- // GuildPass Mobile: Exit functional execution container scope block.
}
- // GuildPass Mobile: Return evaluated JSX layout or callback response.
return (
@@ -80,7 +73,10 @@ export default function GuildDetail() {
Your Membership
-
+
Status
);
- // GuildPass Mobile: Exit functional execution container scope block.
}
diff --git a/src/features/access/useAccessCheck.ts b/src/features/access/useAccessCheck.ts
deleted file mode 100644
index 4f31727..0000000
--- a/src/features/access/useAccessCheck.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
-import { useQuery } from "@tanstack/react-query";
-// GuildPass Mobile: Import package module dependencies.
-import { guildPassClient } from "../../lib/guildpassClient";
-
-// GuildPass Mobile: Exposed interface structure for local navigation layouts.
-export const useAccessCheck = () => {
- // GuildPass Mobile: Variable binding and property initialization.
- const checkAccess = (params: {
- walletAddress: string;
- guildId: string;
- resourceId: string;
- // GuildPass Mobile: Enter functional execution container scope block.
- }) => {
- // GuildPass Mobile: Terminate block execution context and send back value.
- return useQuery({
- queryKey: ["access-check", params],
- queryFn: () => guildPassClient.access.checkAccess(params),
- enabled: !!params.walletAddress && !!params.guildId && !!params.resourceId,
- // GuildPass Mobile: Exit functional execution container scope block.
- });
- // GuildPass Mobile: Exit functional execution container scope block.
- };
-
- // GuildPass Mobile: Return evaluated JSX layout or callback response.
- return {
- checkAccess,
- // GuildPass Mobile: Exit functional execution container scope block.
- };
- // GuildPass Mobile: Exit functional execution container scope block.
-};
diff --git a/src/features/access/useAccessCheckResult.ts b/src/features/access/useAccessCheckResult.ts
new file mode 100644
index 0000000..348fafd
--- /dev/null
+++ b/src/features/access/useAccessCheckResult.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+import { guildPassClient } from "../../lib/guildpassClient";
+import { accessCheckKeys, type AccessCheckParams } from "../../lib/queryKeys";
+
+export function useAccessCheckResult(params: AccessCheckParams) {
+ return useQuery({
+ queryKey: accessCheckKeys.detail(params),
+ queryFn: () => guildPassClient.access.checkAccess(params),
+ enabled: !!params.walletAddress && !!params.guildId && !!params.resourceId,
+ });
+}
diff --git a/src/features/guilds/useGuild.ts b/src/features/guilds/useGuild.ts
new file mode 100644
index 0000000..b8aab1a
--- /dev/null
+++ b/src/features/guilds/useGuild.ts
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import { guildPassClient } from "../../lib/guildpassClient";
+import { guildKeys } from "../../lib/queryKeys";
+
+export function createGuildQueryOptions(guildId: string) {
+ return {
+ queryKey: guildKeys.detail(guildId),
+ queryFn: () => guildPassClient.guilds.getGuild({ guildId }),
+ enabled: !!guildId,
+ } as const;
+}
+
+export function useGuild(guildId: string) {
+ return useQuery(createGuildQueryOptions(guildId));
+}
diff --git a/src/features/guilds/useGuildConfig.ts b/src/features/guilds/useGuildConfig.ts
new file mode 100644
index 0000000..30de569
--- /dev/null
+++ b/src/features/guilds/useGuildConfig.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+import { guildPassClient } from "../../lib/guildpassClient";
+import { guildKeys } from "../../lib/queryKeys";
+
+export function useGuildConfig(guildId: string) {
+ return useQuery({
+ queryKey: guildKeys.config(guildId),
+ queryFn: () => guildPassClient.guilds.getGuildConfig({ guildId }),
+ enabled: !!guildId,
+ });
+}
diff --git a/src/features/guilds/useGuildRoles.ts b/src/features/guilds/useGuildRoles.ts
new file mode 100644
index 0000000..f031c23
--- /dev/null
+++ b/src/features/guilds/useGuildRoles.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+import { guildPassClient } from "../../lib/guildpassClient";
+import { guildKeys } from "../../lib/queryKeys";
+
+export function useGuildRoles(guildId: string) {
+ return useQuery({
+ queryKey: guildKeys.roles(guildId),
+ queryFn: () => guildPassClient.roles.getRoles({ guildId }),
+ enabled: !!guildId,
+ });
+}
diff --git a/src/features/guilds/useGuilds.ts b/src/features/guilds/useGuilds.ts
deleted file mode 100644
index 4810248..0000000
--- a/src/features/guilds/useGuilds.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
-import { useQuery } from "@tanstack/react-query";
-// GuildPass Mobile: Import package module dependencies.
-import { guildPassClient } from "../../lib/guildpassClient";
-
-// GuildPass Mobile: Core mobile screen or hook export definition.
-export const useGuilds = () => {
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const getGuild = (guildId: string) => {
- // GuildPass Mobile: Terminate block execution context and send back value.
- return useQuery({
- queryKey: ["guild", guildId],
- queryFn: () => guildPassClient.guilds.getGuild({ guildId }),
- enabled: !!guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- });
- // GuildPass Mobile: Exit functional execution container scope block.
- };
-
- // GuildPass Mobile: Variable binding and property initialization.
- const getGuildConfig = (guildId: string) => {
- // GuildPass Mobile: Return evaluated JSX layout or callback response.
- return useQuery({
- queryKey: ["guild-config", guildId],
- queryFn: () => guildPassClient.guilds.getGuildConfig({ guildId }),
- enabled: !!guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- });
- // GuildPass Mobile: Exit functional execution container scope block.
- };
-
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const getRoles = (guildId: string) => {
- // GuildPass Mobile: Terminate block execution context and send back value.
- return useQuery({
- queryKey: ["guild-roles", guildId],
- queryFn: () => guildPassClient.roles.getRoles({ guildId }),
- enabled: !!guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- });
- // GuildPass Mobile: Exit functional execution container scope block.
- };
-
- // GuildPass Mobile: Return evaluated JSX layout or callback response.
- return {
- getGuild,
- getGuildConfig,
- getRoles,
- // GuildPass Mobile: Exit functional execution container scope block.
- };
- // GuildPass Mobile: Exit functional execution container scope block.
-};
diff --git a/src/features/membership/useMembership.ts b/src/features/membership/useMembership.ts
index 9a72171..29f3ed4 100644
--- a/src/features/membership/useMembership.ts
+++ b/src/features/membership/useMembership.ts
@@ -1,51 +1,15 @@
-// GuildPass Mobile: Pull in react-native, expo, or external state libraries.
import { useQuery } from "@tanstack/react-query";
-// GuildPass Mobile: Import package module dependencies.
import { guildPassClient } from "../../lib/guildpassClient";
+import { membershipKeys } from "../../lib/queryKeys";
-// GuildPass Mobile: Exported screen, component definition, or state hooks.
-export const useMembership = (walletAddress: string | null) => {
- // GuildPass Mobile: Variable binding and property initialization.
- const getMembership = (guildId: string) => {
- // GuildPass Mobile: Terminate block execution context and send back value.
- return useQuery({
- queryKey: ["membership", walletAddress, guildId],
- queryFn: () =>
- // GuildPass Mobile: Enter functional execution container scope block.
- guildPassClient.membership.getMembership({
- walletAddress: walletAddress!,
- guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- }),
- enabled: !!walletAddress && !!guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- });
- // GuildPass Mobile: Exit functional execution container scope block.
- };
-
- // GuildPass Mobile: Local UI-scoped constant or state representation.
- const getUserRoles = (guildId: string) => {
- // GuildPass Mobile: Return evaluated JSX layout or callback response.
- return useQuery({
- queryKey: ["user-roles", walletAddress, guildId],
- queryFn: () =>
- // GuildPass Mobile: Enter functional execution container scope block.
- guildPassClient.roles.getUserRoles({
- walletAddress: walletAddress!,
- guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- }),
- enabled: !!walletAddress && !!guildId,
- // GuildPass Mobile: Exit functional execution container scope block.
- });
- // GuildPass Mobile: Exit functional execution container scope block.
- };
-
- // GuildPass Mobile: Terminate block execution context and send back value.
- return {
- getMembership,
- getUserRoles,
- // GuildPass Mobile: Exit functional execution container scope block.
- };
- // GuildPass Mobile: Exit functional execution container scope block.
-};
+export function useMembership(walletAddress: string | null, guildId: string) {
+ return useQuery({
+ queryKey: membershipKeys.detail(walletAddress, guildId),
+ queryFn: () =>
+ guildPassClient.membership.getMembership({
+ walletAddress: walletAddress!,
+ guildId,
+ }),
+ enabled: !!walletAddress && !!guildId,
+ });
+}
diff --git a/src/features/membership/useUserRoles.ts b/src/features/membership/useUserRoles.ts
new file mode 100644
index 0000000..757036c
--- /dev/null
+++ b/src/features/membership/useUserRoles.ts
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import { guildPassClient } from "../../lib/guildpassClient";
+import { membershipKeys } from "../../lib/queryKeys";
+
+export function useUserRoles(walletAddress: string | null, guildId: string) {
+ return useQuery({
+ queryKey: membershipKeys.userRoles(walletAddress, guildId),
+ queryFn: () =>
+ guildPassClient.roles.getUserRoles({
+ walletAddress: walletAddress!,
+ guildId,
+ }),
+ enabled: !!walletAddress && !!guildId,
+ });
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
new file mode 100644
index 0000000..b860fc1
--- /dev/null
+++ b/src/lib/queryKeys.ts
@@ -0,0 +1,22 @@
+export type AccessCheckParams = {
+ walletAddress: string;
+ guildId: string;
+ resourceId: string;
+};
+
+export const guildKeys = {
+ detail: (guildId: string) => ["guild", guildId] as const,
+ config: (guildId: string) => ["guild-config", guildId] as const,
+ roles: (guildId: string) => ["guild-roles", guildId] as const,
+};
+
+export const membershipKeys = {
+ detail: (walletAddress: string | null, guildId: string) =>
+ ["membership", walletAddress, guildId] as const,
+ userRoles: (walletAddress: string | null, guildId: string) =>
+ ["user-roles", walletAddress, guildId] as const,
+};
+
+export const accessCheckKeys = {
+ detail: (params: AccessCheckParams) => ["access-check", params] as const,
+};
diff --git a/tests/hooks/queryKeys.test.ts b/tests/hooks/queryKeys.test.ts
new file mode 100644
index 0000000..545fa5b
--- /dev/null
+++ b/tests/hooks/queryKeys.test.ts
@@ -0,0 +1,42 @@
+/**
+ * queryKeys – shared query key helper tests
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ accessCheckKeys,
+ guildKeys,
+ membershipKeys,
+} from "../../src/lib/queryKeys";
+import { TEST_WALLET_ADDRESS } from "../fixtures/membership.fixtures";
+
+describe("queryKeys", () => {
+ it("creates stable guild query keys", () => {
+ expect(guildKeys.detail("guild_abc")).toStrictEqual(["guild", "guild_abc"]);
+ expect(guildKeys.config("guild_abc")).toStrictEqual(["guild-config", "guild_abc"]);
+ expect(guildKeys.roles("guild_abc")).toStrictEqual(["guild-roles", "guild_abc"]);
+ });
+
+ it("creates wallet-scoped membership query keys", () => {
+ expect(membershipKeys.detail(TEST_WALLET_ADDRESS, "guild_abc")).toStrictEqual([
+ "membership",
+ TEST_WALLET_ADDRESS,
+ "guild_abc",
+ ]);
+ expect(membershipKeys.userRoles(TEST_WALLET_ADDRESS, "guild_abc")).toStrictEqual([
+ "user-roles",
+ TEST_WALLET_ADDRESS,
+ "guild_abc",
+ ]);
+ });
+
+ it("creates access-check query keys from params", () => {
+ const params = {
+ walletAddress: TEST_WALLET_ADDRESS,
+ guildId: "guild_abc",
+ resourceId: "secret-channel",
+ };
+
+ expect(accessCheckKeys.detail(params)).toStrictEqual(["access-check", params]);
+ });
+});
diff --git a/tests/hooks/useGuild.hook.test.ts b/tests/hooks/useGuild.hook.test.ts
new file mode 100644
index 0000000..c4c2e0c
--- /dev/null
+++ b/tests/hooks/useGuild.hook.test.ts
@@ -0,0 +1,80 @@
+/**
+ * useGuild hook – refactored query hook tests
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { QueryClient } from "@tanstack/react-query";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { GUILD_DETAIL_FIXTURE } from "../fixtures/guild.fixtures";
+import { guildKeys } from "../../src/lib/queryKeys";
+
+const getGuild = vi.fn();
+
+vi.mock("../../src/lib/guildpassClient", () => ({
+ guildPassClient: {
+ guilds: {
+ getGuild: (...args: unknown[]) => getGuild(...args),
+ },
+ },
+}));
+
+import { createGuildQueryOptions, useGuild } from "../../src/features/guilds/useGuild";
+
+describe("useGuild", () => {
+ beforeEach(() => {
+ getGuild.mockReset();
+ getGuild.mockResolvedValue(GUILD_DETAIL_FIXTURE);
+ });
+
+ it("calls useQuery directly from the custom hook module", () => {
+ const source = readFileSync(
+ resolve(__dirname, "../../src/features/guilds/useGuild.ts"),
+ "utf8",
+ );
+
+ expect(source).toContain("return useQuery(createGuildQueryOptions(guildId));");
+ expect(source).not.toMatch(/return\s*\{\s*getGuild/);
+ });
+
+ it("builds stable query options with the shared query key helper", () => {
+ const options = createGuildQueryOptions("guild_abc");
+
+ expect(options.queryKey).toStrictEqual(guildKeys.detail("guild_abc"));
+ expect(options.enabled).toBe(true);
+ });
+
+ it("does not enable the query when guildId is empty", () => {
+ const options = createGuildQueryOptions("");
+
+ expect(options.enabled).toBe(false);
+ });
+
+ it("fetches guild data through the refactored query options", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ const result = await queryClient.fetchQuery(createGuildQueryOptions("guild_abc"));
+
+ expect(getGuild).toHaveBeenCalledWith({ guildId: "guild_abc" });
+ expect(result).toStrictEqual(GUILD_DETAIL_FIXTURE);
+ });
+
+ it("surfaces SDK errors through the refactored query options", async () => {
+ getGuild.mockRejectedValueOnce(new Error("Network request failed"));
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await expect(queryClient.fetchQuery(createGuildQueryOptions("guild_abc"))).rejects.toThrow(
+ "Network request failed",
+ );
+ });
+
+ it("exports a use* hook entry point for screens", () => {
+ expect(typeof useGuild).toBe("function");
+ expect(useGuild.name).toBe("useGuild");
+ });
+});