Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:25:35 +08:00
commit 4c2042cce6
23 changed files with 5180 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
// src/hooks/useUsers.ts - Example of advanced custom hooks pattern
import { useQuery, useMutation, useQueryClient, queryOptions } from '@tanstack/react-query'
/**
* Type definitions
*/
export type User = {
id: number
name: string
email: string
phone: string
}
export type CreateUserInput = Omit<User, 'id'>
export type UpdateUserInput = Partial<User> & { id: number }
/**
* API functions - centralized network logic
*/
const userApi = {
getAll: async (): Promise<User[]> => {
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) throw new Error('Failed to fetch users')
return response.json()
},
getById: async (id: number): Promise<User> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
if (!response.ok) throw new Error(`Failed to fetch user ${id}`)
return response.json()
},
create: async (user: CreateUserInput): Promise<User> => {
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
})
if (!response.ok) throw new Error('Failed to create user')
return response.json()
},
update: async ({ id, ...updates }: UpdateUserInput): Promise<User> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) throw new Error('Failed to update user')
return response.json()
},
delete: async (id: number): Promise<void> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Failed to delete user')
},
}
/**
* Query options factories (v5 best practice)
*
* Benefits:
* - Type-safe reusable query configurations
* - DRY principle - single source of truth
* - Works with useQuery, useSuspenseQuery, prefetchQuery
* - Easier testing and mocking
*/
export const usersQueryOptions = queryOptions({
queryKey: ['users'],
queryFn: userApi.getAll,
staleTime: 1000 * 60 * 5, // 5 minutes
})
export const userQueryOptions = (id: number) =>
queryOptions({
queryKey: ['users', id],
queryFn: () => userApi.getById(id),
staleTime: 1000 * 60 * 5,
})
/**
* Query Hooks
*/
export function useUsers() {
return useQuery(usersQueryOptions)
}
export function useUser(id: number) {
return useQuery(userQueryOptions(id))
}
/**
* Advanced: Search/Filter Hook
*
* Demonstrates dependent query with filtering
*/
export function useUserSearch(searchTerm: string) {
return useQuery({
queryKey: ['users', 'search', searchTerm],
queryFn: async () => {
const users = await userApi.getAll()
return users.filter(
(user) =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
)
},
enabled: searchTerm.length >= 2, // Only search if 2+ characters
staleTime: 1000 * 30, // 30 seconds for search results
})
}
/**
* Mutation Hooks
*/
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userApi.create,
onSuccess: (newUser) => {
// Update cache with new user
queryClient.setQueryData<User[]>(['users'], (old = []) => [...old, newUser])
// Invalidate to refetch and ensure consistency
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userApi.update,
onSuccess: (updatedUser) => {
// Update individual user cache
queryClient.setQueryData(['users', updatedUser.id], updatedUser)
// Update user in list
queryClient.setQueryData<User[]>(['users'], (old = []) =>
old.map((user) => (user.id === updatedUser.id ? updatedUser : user))
)
},
})
}
export function useDeleteUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userApi.delete,
onSuccess: (_, deletedId) => {
// Remove from cache
queryClient.setQueryData<User[]>(['users'], (old = []) =>
old.filter((user) => user.id !== deletedId)
)
// Remove individual query
queryClient.removeQueries({ queryKey: ['users', deletedId] })
},
})
}
/**
* Advanced: Prefetch Hook
*
* Prefetch user details on hover for instant navigation
*/
export function usePrefetchUser() {
const queryClient = useQueryClient()
return (id: number) => {
queryClient.prefetchQuery(userQueryOptions(id))
}
}
/**
* Component Usage Examples
*/
// Example 1: List all users
export function UserList() {
const { data: users, isPending, isError, error } = useUsers()
const prefetchUser = usePrefetchUser()
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return (
<ul>
{users.map((user) => (
<li
key={user.id}
onMouseEnter={() => prefetchUser(user.id)} // Prefetch on hover
>
<a href={`/users/${user.id}`}>{user.name}</a>
</li>
))}
</ul>
)
}
// Example 2: User detail page
export function UserDetail({ id }: { id: number }) {
const { data: user, isPending } = useUser(id)
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
const { mutate: deleteUser } = useDeleteUser()
if (isPending) return <div>Loading...</div>
if (!user) return <div>User not found</div>
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<button
onClick={() => updateUser({ id: user.id, name: 'Updated Name' })}
disabled={isUpdating}
>
Update Name
</button>
<button onClick={() => deleteUser(user.id)}>
Delete User
</button>
</div>
)
}
// Example 3: Search users
export function UserSearch() {
const [search, setSearch] = useState('')
const { data: results, isFetching } = useUserSearch(search)
return (
<div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
{isFetching && <span>Searching...</span>}
{results && (
<ul>
{results.map((user) => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
)}
</div>
)
}
/**
* Key patterns demonstrated:
*
* 1. API Layer: Centralized fetch functions
* 2. Query Options Factories: Reusable queryOptions
* 3. Custom Hooks: Encapsulate query logic
* 4. Mutation Hooks: Encapsulate mutation logic
* 5. Cache Updates: setQueryData, invalidateQueries, removeQueries
* 6. Prefetching: Improve perceived performance
* 7. Conditional Queries: enabled option
* 8. Search/Filter: Derived queries from base data
*
* Benefits:
* ✅ Type safety throughout
* ✅ Easy to test (mock API layer)
* ✅ Reusable across components
* ✅ Consistent error handling
* ✅ Optimized caching strategy
* ✅ Better code organization
*/

View File

@@ -0,0 +1,248 @@
// src/main.tsx - Complete DevTools Setup
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
/**
* QueryClient with DevTools-friendly configuration
*/
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/*
ReactQueryDevtools Configuration
IMPORTANT: DevTools are automatically tree-shaken in production
Safe to leave in code, won't appear in production bundle
*/}
<ReactQueryDevtools
// Start collapsed (default: false)
initialIsOpen={false}
// Button position on screen
buttonPosition="bottom-right" // "top-left" | "top-right" | "bottom-left" | "bottom-right"
// Panel position when open
position="bottom" // "top" | "bottom" | "left" | "right"
// Custom styles for toggle button
toggleButtonProps={{
style: {
marginBottom: '4rem', // Move up if button overlaps content
marginRight: '1rem',
},
}}
// Custom styles for panel
panelProps={{
style: {
height: '400px', // Custom panel height
},
}}
// Add keyboard shortcut (optional)
// Default: None, but you can add custom handler
/>
</QueryClientProvider>
</StrictMode>
)
/**
* Advanced: Conditional DevTools (explicit dev check)
*
* DevTools are already removed in production, but can add explicit check
*/
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</StrictMode>
)
/**
* Advanced: Custom Toggle Button
*/
import { useState } from 'react'
function AppWithCustomDevTools() {
const [showDevTools, setShowDevTools] = useState(false)
return (
<QueryClientProvider client={queryClient}>
<App />
{/* Custom toggle button */}
<button
onClick={() => setShowDevTools(!showDevTools)}
style={{
position: 'fixed',
bottom: '1rem',
right: '1rem',
zIndex: 99999,
}}
>
{showDevTools ? 'Hide' : 'Show'} DevTools
</button>
{showDevTools && <ReactQueryDevtools initialIsOpen={true} />}
</QueryClientProvider>
)
}
/**
* DevTools Features (what you can do):
*
* 1. View all queries: See queryKey, status, data, error
* 2. Inspect cache: View cached data for each query
* 3. Manual refetch: Force refetch any query
* 4. View mutations: See in-flight and completed mutations
* 5. Query invalidation: Manually invalidate queries
* 6. Explorer mode: Navigate query hierarchy
* 7. Time travel: See query state over time
* 8. Export state: Download current cache for debugging
*
* DevTools Panel Sections:
* - Queries: All active/cached queries
* - Mutations: Recent mutations
* - Query Cache: Full cache state
* - Mutation Cache: Mutation history
* - Settings: DevTools configuration
*/
/**
* Debugging with DevTools
*/
// Example: Check if query is being cached correctly
function DebugQueryCaching() {
const { data, dataUpdatedAt, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<div>
<p>Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}</p>
<p>Is fetching: {isFetching ? 'Yes' : 'No'}</p>
{/* Open DevTools to see:
- Query status (fresh, fetching, stale)
- Cache data
- Refetch behavior
*/}
</div>
)
}
// Example: Debug why query keeps refetching
function DebugRefetchingIssue() {
const { data, isFetching, isRefetching } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
// Check in DevTools if these settings are correct:
staleTime: 0, // ❌ Data always stale, will refetch frequently
refetchOnWindowFocus: true, // ❌ Refetches on every focus
refetchOnMount: true, // ❌ Refetches on every mount
})
// DevTools will show you:
// - How many times query refetched
// - When it refetched (mount, focus, reconnect)
// - Current staleTime and gcTime settings
return <div>Fetching: {isFetching ? 'Yes' : 'No'}</div>
}
/**
* Production DevTools (optional, separate package)
*
* For debugging production issues remotely
* npm install @tanstack/react-query-devtools-production
*/
import { ReactQueryDevtools as ReactQueryDevtoolsProd } from '@tanstack/react-query-devtools-production'
function AppWithProductionDevTools() {
const [showDevTools, setShowDevTools] = useState(false)
useEffect(() => {
// Load production devtools on demand
// Only when user presses keyboard shortcut or secret URL
if (showDevTools) {
import('@tanstack/react-query-devtools-production').then((module) => {
// Module loaded
})
}
}, [showDevTools])
return (
<QueryClientProvider client={queryClient}>
<App />
{showDevTools && <ReactQueryDevtoolsProd />}
</QueryClientProvider>
)
}
/**
* Keyboard Shortcuts (DIY)
*
* Add custom keyboard shortcut to toggle DevTools
*/
function AppWithKeyboardShortcut() {
const [showDevTools, setShowDevTools] = useState(false)
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Ctrl/Cmd + Shift + D
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'd') {
e.preventDefault()
setShowDevTools((prev) => !prev)
}
}
window.addEventListener('keydown', handleKeyPress)
return () => window.removeEventListener('keydown', handleKeyPress)
}, [])
return (
<QueryClientProvider client={queryClient}>
<App />
{showDevTools && <ReactQueryDevtools />}
</QueryClientProvider>
)
}
/**
* Best Practices:
*
* ✅ Keep DevTools in code (tree-shaken in production)
* ✅ Start with initialIsOpen={false} to avoid distraction
* ✅ Use DevTools to debug cache issues
* ✅ Check DevTools when queries refetch unexpectedly
* ✅ Export state for bug reports
*
* ❌ Don't ship production devtools without authentication
* ❌ Don't rely on DevTools for production monitoring
* ❌ Don't expose sensitive data in cache (use select to filter)
*
* Performance:
* - DevTools have minimal performance impact in dev
* - Completely removed in production builds
* - No runtime overhead when not open
*/

View File

@@ -0,0 +1,243 @@
// src/components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react'
import { QueryErrorResetBoundary } from '@tanstack/react-query'
/**
* Props and State types
*/
type ErrorBoundaryProps = {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
}
type ErrorBoundaryState = {
hasError: boolean
error: Error | null
}
/**
* React Error Boundary Class Component
*
* Required because error boundaries must be class components
*/
class ErrorBoundaryClass extends Component<
ErrorBoundaryProps & { onReset?: () => void },
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps & { onReset?: () => void }) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error to error reporting service
console.error('Error caught by boundary:', error, errorInfo)
// Example: Send to Sentry, LogRocket, etc.
// Sentry.captureException(error, { contexts: { react: errorInfo } })
}
handleReset = () => {
// Call TanStack Query reset if provided
this.props.onReset?.()
// Reset error boundary state
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset)
}
// Default error UI
return (
<div
style={{
padding: '2rem',
border: '2px solid #ef4444',
borderRadius: '8px',
backgroundColor: '#fee',
}}
>
<h2>Something went wrong</h2>
<details style={{ whiteSpace: 'pre-wrap', marginTop: '1rem' }}>
<summary>Error details</summary>
{this.state.error.message}
{this.state.error.stack && (
<pre style={{ marginTop: '1rem', fontSize: '0.875rem' }}>
{this.state.error.stack}
</pre>
)}
</details>
<button
onClick={this.handleReset}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Try again
</button>
</div>
)
}
return this.props.children
}
}
/**
* Error Boundary with TanStack Query Reset
*
* Wraps components and catches errors thrown by queries
* with throwOnError: true
*/
export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundaryClass onReset={reset} fallback={fallback}>
{children}
</ErrorBoundaryClass>
)}
</QueryErrorResetBoundary>
)
}
/**
* Usage Examples
*/
// Example 1: Wrap entire app
export function AppWithErrorBoundary() {
return (
<ErrorBoundary>
<App />
</ErrorBoundary>
)
}
// Example 2: Wrap specific features
export function UserProfileWithErrorBoundary() {
return (
<ErrorBoundary>
<UserProfile />
</ErrorBoundary>
)
}
// Example 3: Custom error UI
export function CustomErrorBoundary({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div className="error-container">
<h1>Oops!</h1>
<p>We encountered an error: {error.message}</p>
<button onClick={reset}>Retry</button>
<a href="/">Go Home</a>
</div>
)}
>
{children}
</ErrorBoundary>
)
}
/**
* Using throwOnError with Queries
*
* Queries can throw errors to error boundaries
*/
import { useQuery } from '@tanstack/react-query'
// Example 1: Always throw errors
function UserData({ id }: { id: number }) {
const { data } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('User not found')
return response.json()
},
throwOnError: true, // Throw to error boundary
})
return <div>{data.name}</div>
}
// Example 2: Conditional throwing (only server errors)
function ConditionalErrorThrowing({ id }: { id: number }) {
const { data } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
},
throwOnError: (error) => {
// Only throw 5xx server errors to boundary
// Handle 4xx client errors locally
return error.message.includes('5')
},
})
return <div>{data?.name ?? 'Not found'}</div>
}
/**
* Multiple Error Boundaries (Layered)
*
* Place boundaries at different levels for granular error handling
*/
export function LayeredErrorBoundaries() {
return (
// App-level boundary
<ErrorBoundary fallback={(error) => <AppCrashScreen error={error} />}>
<Header />
{/* Feature-level boundary */}
<ErrorBoundary fallback={(error) => <FeatureError error={error} />}>
<UserProfile />
</ErrorBoundary>
{/* Another feature boundary */}
<ErrorBoundary>
<TodoList />
</ErrorBoundary>
<Footer />
</ErrorBoundary>
)
}
/**
* Key concepts:
*
* 1. QueryErrorResetBoundary: Provides reset function for TanStack Query
* 2. throwOnError: Makes query throw errors to boundary
* 3. Layered boundaries: Isolate failures to specific features
* 4. Custom fallbacks: Control error UI per boundary
* 5. Error logging: componentDidCatch for monitoring
*
* Best practices:
* ✅ Always wrap app in error boundary
* ✅ Use throwOnError for critical errors only
* ✅ Provide helpful error messages to users
* ✅ Log errors to monitoring service
* ✅ Offer reset/retry functionality
* ❌ Don't catch all errors - use local error states when appropriate
* ❌ Don't throw for expected errors (404, validation)
*/

31
templates/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "my-app-with-tanstack-query",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.90.5"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/eslint-plugin-query": "^5.90.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.16.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"typescript": "^5.6.3",
"vite": "^6.0.1"
}
}

View File

@@ -0,0 +1,50 @@
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from './lib/query-client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* DevTools are automatically removed in production builds */}
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
position="bottom"
/>
</QueryClientProvider>
</StrictMode>
)
/**
* Important notes:
*
* 1. QueryClientProvider must wrap all components that use TanStack Query hooks
* 2. DevTools must be inside the provider
* 3. DevTools are tree-shaken in production (safe to leave in code)
* 4. Only create ONE QueryClient instance for entire app (imported from query-client.ts)
*
* DevTools configuration options:
* - initialIsOpen: true/false - Start open or closed
* - buttonPosition: "top-left" | "top-right" | "bottom-left" | "bottom-right"
* - position: "top" | "bottom" | "left" | "right"
* - toggleButtonProps: Custom button styles
* - panelProps: Custom panel styles
*
* Example with custom styles:
* <ReactQueryDevtools
* initialIsOpen={false}
* buttonPosition="bottom-right"
* toggleButtonProps={{
* style: { marginBottom: '4rem' }
* }}
* panelProps={{
* style: { height: '500px' }
* }}
* />
*/

View File

@@ -0,0 +1,72 @@
// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
/**
* QueryClient configuration for TanStack Query v5
*
* Key settings:
* - staleTime: How long data is fresh (won't refetch)
* - gcTime: How long inactive data stays in cache (garbage collection time)
* - retry: Number of retry attempts on failure
* - refetchOnWindowFocus: Refetch when window regains focus
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Data is fresh for 5 minutes (won't refetch during this time)
staleTime: 1000 * 60 * 5,
// Inactive data stays in cache for 1 hour before garbage collection
gcTime: 1000 * 60 * 60,
// Retry failed requests with smart logic
retry: (failureCount, error) => {
// Don't retry on 404s
if (error instanceof Response && error.status === 404) {
return false
}
// Retry up to 3 times for other errors
return failureCount < 3
},
// Don't refetch on window focus (can be annoying during dev)
// Set to true for real-time data (stock prices, notifications)
refetchOnWindowFocus: false,
// Refetch when network reconnects
refetchOnReconnect: true,
// Refetch on component mount if data is stale
refetchOnMount: true,
},
mutations: {
// Don't retry mutations by default (usually not wanted)
retry: 0,
// Global mutation error handler (optional)
onError: (error) => {
console.error('Mutation error:', error)
// Add global error handling here (toast, alert, etc.)
},
},
},
})
/**
* Adjust these settings based on your needs:
*
* For real-time data (stock prices, notifications):
* - staleTime: 0 (always stale, refetch frequently)
* - refetchOnWindowFocus: true
* - refetchInterval: 1000 * 30 (refetch every 30s)
*
* For static data (user settings, app config):
* - staleTime: Infinity (never stale)
* - refetchOnWindowFocus: false
* - refetchOnMount: false
*
* For moderate data (todos, posts):
* - staleTime: 1000 * 60 * 5 (5 minutes)
* - refetchOnWindowFocus: false
* - refetchOnMount: true
*/

View File

@@ -0,0 +1,214 @@
// src/hooks/useInfiniteTodos.ts
import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import type { Todo } from './useTodos'
/**
* Paginated response type
*/
type TodosPage = {
data: Todo[]
nextCursor: number | null
previousCursor: number | null
}
/**
* Fetch paginated todos
*
* In real API: cursor would be offset, page number, or last item ID
*/
async function fetchTodosPage({ pageParam }: { pageParam: number }): Promise<TodosPage> {
const limit = 20
const start = pageParam * limit
const end = start + limit
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos?_start=${start}&_limit=${limit}`
)
if (!response.ok) {
throw new Error('Failed to fetch todos')
}
const data: Todo[] = await response.json()
return {
data,
nextCursor: data.length === limit ? pageParam + 1 : null,
previousCursor: pageParam > 0 ? pageParam - 1 : null,
}
}
/**
* Infinite query hook
*
* Usage:
* const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteTodos()
*/
export function useInfiniteTodos() {
return useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: fetchTodosPage,
// v5 REQUIRES initialPageParam (was optional in v4)
initialPageParam: 0,
// Determine if there are more pages
getNextPageParam: (lastPage) => lastPage.nextCursor,
// Optional: Determine if there are previous pages (bidirectional)
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
// How many pages to keep in memory (default: Infinity)
maxPages: undefined,
})
}
/**
* Component with manual "Load More" button
*/
export function InfiniteTodosManual() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
isError,
error,
} = useInfiniteTodos()
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return (
<div>
<h1>Infinite Todos (Manual)</h1>
{/* Render all pages */}
{data.pages.map((page, i) => (
<div key={i}>
<h2>Page {i + 1}</h2>
<ul>
{page.data.map((todo) => (
<li key={todo.id}>
<input type="checkbox" checked={todo.completed} readOnly />
{todo.title}
</li>
))}
</ul>
</div>
))}
{/* Load more button */}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more todos'}
</button>
</div>
)
}
/**
* Component with automatic infinite scroll
* Uses Intersection Observer to detect when user scrolls to bottom
*/
export function InfiniteTodosAuto() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
isError,
error,
} = useInfiniteTodos()
const loadMoreRef = useRef<HTMLDivElement>(null)
// Intersection Observer for automatic loading
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// When sentinel element is visible and there are more pages
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 } // Trigger when 10% of element is visible
)
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => {
observer.disconnect()
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return (
<div>
<h1>Infinite Todos (Auto)</h1>
{/* Render all pages */}
{data.pages.map((page, i) => (
<div key={i}>
{page.data.map((todo) => (
<div key={todo.id}>
<input type="checkbox" checked={todo.completed} readOnly />
{todo.title}
</div>
))}
</div>
))}
{/* Sentinel element - triggers loading when scrolled into view */}
<div ref={loadMoreRef}>
{isFetchingNextPage ? (
<div>Loading more...</div>
) : hasNextPage ? (
<div>Scroll to load more</div>
) : (
<div>No more todos</div>
)}
</div>
</div>
)
}
/**
* Key concepts:
*
* 1. data.pages: Array of all fetched pages
* 2. fetchNextPage(): Loads next page
* 3. hasNextPage: Boolean if more pages available
* 4. isFetchingNextPage: Loading state for next page
* 5. initialPageParam: Starting cursor (REQUIRED in v5)
* 6. getNextPageParam: Function returning next cursor or null
*
* Access all data:
* const allTodos = data.pages.flatMap(page => page.data)
*
* Bidirectional scrolling:
* - Add getPreviousPageParam
* - Use fetchPreviousPage() and hasPreviousPage
*
* Performance:
* - Use maxPages to limit memory (e.g., maxPages: 10)
* - Old pages are garbage collected automatically
*
* Common patterns:
* - Manual: Load More button
* - Auto: Intersection Observer
* - Virtualized: react-window or react-virtual for huge lists
*/

View File

@@ -0,0 +1,201 @@
// src/hooks/useTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Todo } from './useTodos'
/**
* Input types for mutations
*/
type AddTodoInput = {
title: string
completed?: boolean
}
type UpdateTodoInput = {
id: number
title?: string
completed?: boolean
}
/**
* API functions
*/
async function addTodo(newTodo: AddTodoInput): Promise<Todo> {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newTodo, userId: 1 }),
})
if (!response.ok) {
throw new Error(`Failed to add todo: ${response.statusText}`)
}
return response.json()
}
async function updateTodo({ id, ...updates }: UpdateTodoInput): Promise<Todo> {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
}
)
if (!response.ok) {
throw new Error(`Failed to update todo: ${response.statusText}`)
}
return response.json()
}
async function deleteTodo(id: number): Promise<void> {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
throw new Error(`Failed to delete todo: ${response.statusText}`)
}
}
/**
* Hook: Add new todo
*
* Usage:
* const { mutate, isPending, isError, error } = useAddTodo()
* mutate({ title: 'New todo' })
*/
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
// Runs on successful mutation
onSuccess: () => {
// Invalidate todos query to trigger refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
// Runs on error
onError: (error) => {
console.error('Failed to add todo:', error)
// Add user notification here (toast, alert, etc.)
},
// Runs regardless of success or error
onSettled: () => {
console.log('Add todo mutation completed')
},
})
}
/**
* Hook: Update existing todo
*
* Usage:
* const { mutate } = useUpdateTodo()
* mutate({ id: 1, completed: true })
*/
export function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
// Update specific todo in cache
queryClient.setQueryData<Todo>(['todos', updatedTodo.id], updatedTodo)
// Invalidate list to refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
/**
* Hook: Delete todo
*
* Usage:
* const { mutate } = useDeleteTodo()
* mutate(todoId)
*/
export function useDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteTodo,
onSuccess: (_, deletedId) => {
// Remove from list cache
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.filter((todo) => todo.id !== deletedId)
)
// Remove individual todo cache
queryClient.removeQueries({ queryKey: ['todos', deletedId] })
},
})
}
/**
* Component usage example:
*/
export function AddTodoForm() {
const { mutate, isPending, isError, error } = useAddTodo()
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const title = formData.get('title') as string
mutate(
{ title },
{
// Optional per-mutation callbacks
onSuccess: () => {
e.currentTarget.reset()
console.log('Todo added successfully!')
},
}
)
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="title"
placeholder="New todo..."
required
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add Todo'}
</button>
{isError && <div>Error: {error.message}</div>}
</form>
)
}
/**
* Key concepts:
*
* 1. Mutations don't cache data (unlike queries)
* 2. Use onSuccess to invalidate related queries
* 3. queryClient.invalidateQueries() marks queries as stale and refetches
* 4. queryClient.setQueryData() directly updates cache (optimistic update)
* 5. queryClient.removeQueries() removes specific query from cache
*
* Mutation states:
* - isPending: Mutation in progress
* - isError: Mutation failed
* - isSuccess: Mutation succeeded
* - data: Returned data from mutationFn
* - error: Error if mutation failed
*/

View File

@@ -0,0 +1,234 @@
// src/hooks/useOptimisticTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Todo } from './useTodos'
/**
* Optimistic Update Pattern
*
* Updates UI immediately before server responds, then:
* - On success: Keep the optimistic update
* - On error: Roll back to previous state
*
* Best for:
* - Low-risk actions (toggle, like, favorite)
* - Frequently used actions (better UX with instant feedback)
*
* Avoid for:
* - Critical operations (payments, account changes)
* - Complex validations (server might reject)
*/
type AddTodoInput = {
title: string
}
type UpdateTodoInput = {
id: number
completed: boolean
}
/**
* Optimistic Add Todo
*
* Immediately shows new todo in UI, then confirms with server
*/
export function useOptimisticAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newTodo: AddTodoInput) => {
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newTodo, userId: 1, completed: false }),
}
)
if (!response.ok) throw new Error('Failed to add todo')
return response.json()
},
// Before mutation runs
onMutate: async (newTodo) => {
// Cancel outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot current value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistically update cache
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
{
id: Date.now(), // Temporary ID
...newTodo,
completed: false,
userId: 1,
},
])
// Return context with snapshot (used for rollback)
return { previousTodos }
},
// If mutation fails, rollback using context
onError: (err, newTodo, context) => {
console.error('Failed to add todo:', err)
// Restore previous state
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
// Always refetch after mutation settles (success or error)
// Ensures cache matches server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
/**
* Optimistic Update Todo
*
* Immediately toggles todo in UI, confirms with server
*/
export function useOptimisticUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, completed }: UpdateTodoInput) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
}
)
if (!response.ok) throw new Error('Failed to update todo')
return response.json()
},
onMutate: async ({ id, completed }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistic update
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map((todo) =>
todo.id === id ? { ...todo, completed } : todo
)
)
return { previousTodos }
},
onError: (err, variables, context) => {
console.error('Failed to update todo:', err)
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] })
},
})
}
/**
* Optimistic Delete Todo
*
* Immediately removes todo from UI, confirms with server
*/
export function useOptimisticDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${id}`,
{
method: 'DELETE',
}
)
if (!response.ok) throw new Error('Failed to delete todo')
},
onMutate: async (deletedId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistically remove from cache
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.filter((todo) => todo.id !== deletedId)
)
return { previousTodos }
},
onError: (err, variables, context) => {
console.error('Failed to delete todo:', err)
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
/**
* Component usage example:
*/
export function OptimisticTodoItem({ todo }: { todo: Todo }) {
const { mutate: updateTodo, isPending: isUpdating } = useOptimisticUpdateTodo()
const { mutate: deleteTodo, isPending: isDeleting } = useOptimisticDeleteTodo()
return (
<li style={{ opacity: isUpdating || isDeleting ? 0.5 : 1 }}>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => updateTodo({ id: todo.id, completed: e.target.checked })}
disabled={isUpdating || isDeleting}
/>
<span>{todo.title}</span>
<button
onClick={() => deleteTodo(todo.id)}
disabled={isUpdating || isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</li>
)
}
/**
* Key patterns:
*
* 1. onMutate: Cancel queries, snapshot state, update cache optimistically
* 2. onError: Rollback using context
* 3. onSettled: Refetch to ensure cache matches server (always runs)
* 4. cancelQueries: Prevent race conditions
* 5. Return context from onMutate: Available in onError and onSettled
*
* Trade-offs:
* ✅ Instant UI feedback (feels faster)
* ✅ Better UX for common actions
* ❌ More complex code
* ❌ Risk of inconsistent state if not handled correctly
* ❌ Not suitable for critical operations
*/

View File

@@ -0,0 +1,119 @@
// src/hooks/useTodos.ts
import { useQuery, queryOptions } from '@tanstack/react-query'
/**
* Type definitions
*/
export type Todo = {
id: number
title: string
completed: boolean
userId: number
}
/**
* API function - keeps network logic separate
*/
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/todos')
if (!response.ok) {
throw new Error(`Failed to fetch todos: ${response.statusText}`)
}
return response.json()
}
/**
* Query options factory (v5 best practice)
*
* Benefits:
* - Reusable across useQuery, useSuspenseQuery, prefetchQuery
* - Perfect type inference
* - Single source of truth for queryKey and queryFn
*/
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1 minute
})
/**
* Custom hook - encapsulates query logic
*
* Usage in component:
* const { data, isPending, isError, error } = useTodos()
*/
export function useTodos() {
return useQuery(todosQueryOptions)
}
/**
* Fetch single todo by ID
*/
async function fetchTodoById(id: number): Promise<Todo> {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${id}`
)
if (!response.ok) {
throw new Error(`Failed to fetch todo ${id}: ${response.statusText}`)
}
return response.json()
}
/**
* Custom hook for fetching single todo
*
* Usage:
* const { data: todo } = useTodo(1)
*/
export function useTodo(id: number) {
return useQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodoById(id),
enabled: !!id, // Only fetch if id is truthy
})
}
/**
* Component usage example:
*/
export function TodoList() {
const { data, isPending, isError, error, isFetching } = useTodos()
if (isPending) {
return <div>Loading todos...</div>
}
if (isError) {
return <div>Error: {error.message}</div>
}
return (
<div>
<h1>Todos {isFetching && '(Refetching...)'}</h1>
<ul>
{data.map((todo) => (
<li key={todo.id}>
<input type="checkbox" checked={todo.completed} readOnly />
{todo.title}
</li>
))}
</ul>
</div>
)
}
/**
* Key states explained:
*
* - isPending: No data yet (initial fetch)
* - isLoading: isPending && isFetching (loading for first time)
* - isFetching: Any background fetch in progress
* - isError: Query failed
* - isSuccess: Query succeeded and data is available
* - data: The fetched data (undefined while isPending)
* - error: Error object if query failed
*/