feat: add React Query setup with api helper

- Install @tanstack/react-query
- Create QueryClientProvider in ClientProviders with stable client instance
- Add queryClient.ts with createQueryClient() and api() helper
- Add api() helper that wraps fetch with automatic /api prefix
- Add example.ts with complete CRUD hook examples
- Configure sensible defaults (5min staleTime, retry once)

All API routes are now prefixed with /api automatically via api() helper.
This commit is contained in:
Thomas Hallock
2025-10-05 16:55:03 -05:00
parent 1234e6ce60
commit a3878a8537
5 changed files with 228 additions and 11 deletions

View File

@@ -45,6 +45,7 @@
"@soroban/core": "workspace:*",
"@soroban/templates": "workspace:*",
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"emojibase-data": "^16.0.3",
"lucide-react": "^0.294.0",

View File

@@ -1,27 +1,34 @@
'use client'
import { ReactNode } from 'react'
import { ReactNode, useState } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { AbacusDisplayProvider } from '@soroban/abacus-react'
import { UserProfileProvider } from '@/contexts/UserProfileContext'
import { GameModeProvider } from '@/contexts/GameModeContext'
import { FullscreenProvider } from '@/contexts/FullscreenContext'
import { DeploymentInfo } from './DeploymentInfo'
import { createQueryClient } from '@/lib/queryClient'
interface ClientProvidersProps {
children: ReactNode
}
export function ClientProviders({ children }: ClientProvidersProps) {
// Create a stable QueryClient instance that persists across renders
const [queryClient] = useState(() => createQueryClient())
return (
<AbacusDisplayProvider>
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
<QueryClientProvider client={queryClient}>
<AbacusDisplayProvider>
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
</QueryClientProvider>
)
}

View File

@@ -0,0 +1,126 @@
/**
* Example API hooks using React Query
*
* This file demonstrates how to use React Query with the configured
* QueryClient and apiUrl helper for making API requests.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/queryClient'
// Example type for an API resource
interface User {
id: string
name: string
email: string
}
/**
* Example query hook - fetches a list of users
*/
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await api('users')
if (!response.ok) {
throw new Error('Failed to fetch users')
}
return response.json() as Promise<User[]>
},
})
}
/**
* Example query hook - fetches a single user by ID
*/
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: async () => {
const response = await api(`users/${userId}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
return response.json() as Promise<User>
},
enabled: !!userId, // Only run query if userId is provided
})
}
/**
* Example mutation hook - creates a new user
*/
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newUser: Omit<User, 'id'>) => {
const response = await api('users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newUser),
})
if (!response.ok) {
throw new Error('Failed to create user')
}
return response.json() as Promise<User>
},
onSuccess: () => {
// Invalidate and refetch users query after successful creation
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
/**
* Example mutation hook - updates a user
*/
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (user: User) => {
const response = await api(`users/${user.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
})
if (!response.ok) {
throw new Error('Failed to update user')
}
return response.json() as Promise<User>
},
onSuccess: (updatedUser) => {
// Invalidate both the list and the individual user query
queryClient.invalidateQueries({ queryKey: ['users'] })
queryClient.invalidateQueries({ queryKey: ['users', updatedUser.id] })
},
})
}
/**
* Example mutation hook - deletes a user
*/
export function useDeleteUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (userId: string) => {
const response = await api(`users/${userId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete user')
}
},
onSuccess: () => {
// Invalidate users query after successful deletion
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}

View File

@@ -0,0 +1,67 @@
import { QueryClient, DefaultOptions } from '@tanstack/react-query'
const queryConfig: DefaultOptions = {
queries: {
// Default stale time of 5 minutes
staleTime: 1000 * 60 * 5,
// Retry failed requests once
retry: 1,
// Refetch on window focus in production only
refetchOnWindowFocus: process.env.NODE_ENV === 'production',
},
}
/**
* Creates a new QueryClient instance with default configuration.
* All API routes are expected to be prefixed with /api.
*/
export function createQueryClient() {
return new QueryClient({
defaultOptions: queryConfig,
})
}
/**
* Helper function to construct API URLs with the /api prefix.
* Use this for consistency when making API calls.
*
* @param path - The API path without the /api prefix (e.g., 'users', 'posts/123')
* @returns The full API URL (e.g., '/api/users', '/api/posts/123')
*
* @example
* ```ts
* const url = apiUrl('users') // '/api/users'
* const url = apiUrl('posts/123') // '/api/posts/123'
* ```
*/
export function apiUrl(path: string): string {
// Remove leading slash if present to avoid double slashes
const cleanPath = path.startsWith('/') ? path.slice(1) : path
return `/api/${cleanPath}`
}
/**
* Wrapper around fetch that automatically prefixes paths with /api.
* Provides a consistent way to make API calls throughout the application.
*
* @param path - The API path without the /api prefix (e.g., 'users', 'posts/123')
* @param options - Standard fetch options (method, headers, body, etc.)
* @returns Promise with the fetch Response
*
* @example
* ```ts
* // GET request
* const response = await api('users')
* const users = await response.json()
*
* // POST request
* const response = await api('users', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: JSON.stringify({ name: 'John' })
* })
* ```
*/
export function api(path: string, options?: RequestInit): Promise<Response> {
return fetch(apiUrl(path), options)
}

16
pnpm-lock.yaml generated
View File

@@ -130,6 +130,9 @@ importers:
'@tanstack/react-form':
specifier: ^0.19.0
version: 0.19.0(@types/react@18.2.0)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query':
specifier: ^5.90.2
version: 5.90.2(react@18.2.0)
'@types/jsdom':
specifier: ^21.1.7
version: 21.1.7
@@ -6362,6 +6365,10 @@ packages:
'@tanstack/store': 0.3.1
dev: false
/@tanstack/query-core@5.90.2:
resolution: {integrity: sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==}
dev: false
/@tanstack/react-form@0.19.0(@types/react@18.2.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-9cE+ic8iHN8UtR4k65286qwDk1pF3ESHiLw3aWDUySezduz4K61Jl3CkwDbuMUnUvzymoSlqWm0Lppqzj3ASng==}
peerDependencies:
@@ -6377,6 +6384,15 @@ packages:
- react-dom
dev: false
/@tanstack/react-query@5.90.2(react@18.2.0):
resolution: {integrity: sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==}
peerDependencies:
react: ^18 || ^19
dependencies:
'@tanstack/query-core': 5.90.2
react: 18.2.0
dev: false
/@tanstack/react-store@0.3.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-PfV271d345It6FdcX4c9gd+llKGddtvau8iJnybTAWmYVyDeFWfIIkiAJ5iNITJmI02AzqgtcV3QLNBBlpBUjA==}
peerDependencies: