diff --git a/console/package.json b/console/package.json
index 8edeb892..247ce6cb 100644
--- a/console/package.json
+++ b/console/package.json
@@ -43,6 +43,7 @@
"@rehookify/datepicker": "^3.2.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-hotkeys": "^0.4.1",
+ "@tanstack/react-query": "^5.101.2",
"@tanstack/react-table": "^8.21.3",
"@textea/json-viewer": "^4.0.1",
"@tiptap/core": "3.20.1",
@@ -142,6 +143,7 @@
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.38.0",
"@react-router/dev": "^7.13.0",
+ "@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml
index f0e901a3..a35441ec 100644
--- a/console/pnpm-lock.yaml
+++ b/console/pnpm-lock.yaml
@@ -125,6 +125,9 @@ importers:
'@tanstack/react-hotkeys':
specifier: ^0.4.1
version: 0.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tanstack/react-query':
+ specifier: ^5.101.2
+ version: 5.101.2(react@18.3.1)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -336,6 +339,9 @@ importers:
'@react-router/dev':
specifier: ^7.13.0
version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3))(@types/node@22.19.9)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))
+ '@tanstack/react-query-devtools':
+ specifier: ^5.101.2
+ version: 5.101.2(@tanstack/react-query@5.101.2(react@18.3.1))(react@18.3.1)
'@testing-library/jest-dom':
specifier: ^5.17.0
version: 5.17.0
@@ -2201,6 +2207,12 @@ packages:
resolution: {integrity: sha512-EGHqcdKP2jzy0dEkahA3ABtEXohMqPlU3Ac04sBQjgesJqr9xWuesJotOfWPh3P68kQQg8krNAtFTydIN3+WSw==}
engines: {node: '>=18'}
+ '@tanstack/query-core@5.101.2':
+ resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==}
+
+ '@tanstack/query-devtools@5.101.2':
+ resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
+
'@tanstack/react-hotkeys@0.4.1':
resolution: {integrity: sha512-hFh/kKQODn4kSytfIsEE/Vf1AaAb+NAFi4lx+OB49NmKY5z/BNH1/uEdYlVgOEvnDm4QrCISIMBOVpMgK5QNQg==}
engines: {node: '>=18'}
@@ -2208,6 +2220,17 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
+ '@tanstack/react-query-devtools@5.101.2':
+ resolution: {integrity: sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==}
+ peerDependencies:
+ '@tanstack/react-query': ^5.101.2
+ react: ^18 || ^19
+
+ '@tanstack/react-query@5.101.2':
+ resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@tanstack/react-store@0.9.2':
resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==}
peerDependencies:
@@ -7296,6 +7319,10 @@ snapshots:
dependencies:
'@tanstack/store': 0.9.2
+ '@tanstack/query-core@5.101.2': {}
+
+ '@tanstack/query-devtools@5.101.2': {}
+
'@tanstack/react-hotkeys@0.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/hotkeys': 0.4.1
@@ -7303,6 +7330,17 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
+ '@tanstack/react-query-devtools@5.101.2(@tanstack/react-query@5.101.2(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-devtools': 5.101.2
+ '@tanstack/react-query': 5.101.2(react@18.3.1)
+ react: 18.3.1
+
+ '@tanstack/react-query@5.101.2(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-core': 5.101.2
+ react: 18.3.1
+
'@tanstack/react-store@0.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/store': 0.9.2
diff --git a/console/src/App.tsx b/console/src/App.tsx
index 442bc7c1..0e50d258 100644
--- a/console/src/App.tsx
+++ b/console/src/App.tsx
@@ -1,6 +1,9 @@
import { useMemo } from "react"
import { RouterProvider } from "react-router"
+import { QueryClientProvider } from "@tanstack/react-query"
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { PreferencesProvider } from "./contexts/PreferencesContext"
+import { queryClient } from "./lib/query-client"
import type { RouterProps } from "./views/router"
import { createRouter } from "./views/router"
import { Toaster } from "@/components/ui/sonner"
@@ -9,9 +12,12 @@ export default function App(props: RouterProps) {
const router = useMemo(() => createRouter(props), [props])
return (
-
-
-
-
+
+
+
+
+
+ {import.meta.env.DEV && }
+
)
}
diff --git a/console/src/lib/query-client.ts b/console/src/lib/query-client.ts
new file mode 100644
index 00000000..2d3157c4
--- /dev/null
+++ b/console/src/lib/query-client.ts
@@ -0,0 +1,24 @@
+import { QueryClient } from "@tanstack/react-query"
+
+/**
+ * The shared QueryClient for the console.
+ *
+ * Defaults are tuned for an authenticated dashboard where most data is
+ * project-scoped and changes from other tabs/sessions are rare:
+ *
+ * - `staleTime` of 30s keeps navigation snappy (cached data is shown
+ * instantly) while still revalidating in the background often enough that
+ * the UI never feels stale.
+ * - `retry: 1` avoids hammering the API on genuine 4xx errors while still
+ * tolerating a single transient blip.
+ * - `refetchOnWindowFocus` is left on (the default) so returning to a tab
+ * pulls fresh data without a manual reload.
+ */
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ retry: 1,
+ },
+ },
+})
diff --git a/console/src/lib/query-keys.ts b/console/src/lib/query-keys.ts
new file mode 100644
index 00000000..b4f05bfa
--- /dev/null
+++ b/console/src/lib/query-keys.ts
@@ -0,0 +1,23 @@
+import type { SearchParams } from "@/types"
+import type { UUID } from "@/types/common"
+
+/**
+ * Centralised TanStack Query key factory.
+ *
+ * Keeping every key in one place gives us a single source of truth for cache
+ * invalidation: a mutation can invalidate `queryKeys.users.list(projectId)` to
+ * refresh every paginated/filtered users query, or
+ * `queryKeys.users.all(projectId)` to refresh lists *and* detail views at once.
+ *
+ * Convention: keys are hierarchical, from least to most specific —
+ * [entity] → [entity, projectId] → [entity, projectId, "list", params]
+ * so a prefix match (how `invalidateQueries` works) cascades naturally.
+ */
+export const queryKeys = {
+ users: {
+ all: (projectId: UUID) => ["users", projectId] as const,
+ list: (projectId: UUID, params?: Partial) =>
+ ["users", projectId, "list", params ?? {}] as const,
+ detail: (projectId: UUID, userId: UUID) => ["users", projectId, "detail", userId] as const,
+ },
+} as const
diff --git a/console/src/views/users/Users.tsx b/console/src/views/users/Users.tsx
index 592ef2f1..af19f46c 100644
--- a/console/src/views/users/Users.tsx
+++ b/console/src/views/users/Users.tsx
@@ -18,7 +18,8 @@ import {
import { UserImportDialog } from "@/components/ui/user-import-dialog"
import { NIL } from "uuid"
import { useRoute } from "@/hooks/use-route"
-import { useResolver } from "../../hooks"
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { queryKeys } from "@/lib/query-keys"
import { formatDate, cn } from "../../utils"
import { getRandomColor } from "@/lib/colors"
import { getUserDisplayName, getUserInitials, getPrimaryExternalId } from "@/lib/name"
@@ -94,7 +95,6 @@ export default function Users() {
const [searchQuery, setSearchQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const [isCreateOpen, setIsCreateOpen] = useState(false)
- const [isCreating, setIsCreating] = useState(false)
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false)
const [isNewUserTimezoneOpen, setIsNewUserTimezoneOpen] = useState(false)
const timezones = useMemo(() => Intl.supportedValuesOf("timeZone"), [])
@@ -124,15 +124,22 @@ export default function Users() {
}, 300)
}, [])
- const [result, , reload] = useResolver(
- useCallback(async () => {
- return await api.users.search(projectId, {
- limit,
- offset: (page - 1) * limit,
- search: debouncedQuery || undefined,
- })
- }, [projectId, debouncedQuery, page]),
- )
+ const searchParams = {
+ limit,
+ offset: (page - 1) * limit,
+ search: debouncedQuery || undefined,
+ }
+ const { data: result } = useQuery({
+ queryKey: queryKeys.users.list(projectId, searchParams),
+ queryFn: () => api.users.search(projectId, searchParams),
+ // Keep the previous page's rows visible while the next page loads, so
+ // paging and typing in search don't flash an empty table.
+ placeholderData: keepPreviousData,
+ })
+
+ const queryClient = useQueryClient()
+ const invalidateUsers = () =>
+ queryClient.invalidateQueries({ queryKey: queryKeys.users.all(projectId) })
const users = result?.results
const total = result?.total ?? 0
@@ -140,11 +147,8 @@ export default function Users() {
const hasNextPage = page < totalPages
const hasPrevPage = page > 1
- const createUser = async (data: CreateUserFormValues) => {
- if (!data.external_id?.trim() && !data.email) return
-
- setIsCreating(true)
- try {
+ const createUserMutation = useMutation({
+ mutationFn: async (data: CreateUserFormValues) => {
const newUser: User = {
identifier: data.external_id?.trim()
? [{ source: "default", external_id: data.external_id.trim() }]
@@ -156,21 +160,29 @@ export default function Users() {
data: newUserData,
} as User
- await api.users.create(projectId, newUser)
- await reload()
+ return await api.users.create(projectId, newUser)
+ },
+ onSuccess: async () => {
+ await invalidateUsers()
setIsCreateOpen(false)
form.reset()
setNewUserData({})
- } finally {
- setIsCreating(false)
- }
- }
+ },
+ })
+ const isCreating = createUserMutation.isPending
- const handleImportUsers = async (file: File) => {
- await api.users.addImport(projectId, file)
- await reload()
+ const createUser = (data: CreateUserFormValues) => {
+ if (!data.external_id?.trim() && !data.email) return
+ createUserMutation.mutate(data)
}
+ const importUsersMutation = useMutation({
+ mutationFn: (file: File) => api.users.addImport(projectId, file),
+ onSuccess: () => invalidateUsers(),
+ })
+
+ const handleImportUsers = (file: File) => importUsersMutation.mutateAsync(file)
+
const handleRowClick = (user: User) => {
route(`users/${user.id}`)
}