Initial commit
This commit is contained in:
281
templates/custom-hooks-pattern.tsx
Normal file
281
templates/custom-hooks-pattern.tsx
Normal 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
|
||||
*/
|
||||
248
templates/devtools-setup.tsx
Normal file
248
templates/devtools-setup.tsx
Normal 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
|
||||
*/
|
||||
243
templates/error-boundary.tsx
Normal file
243
templates/error-boundary.tsx
Normal 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
31
templates/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
50
templates/provider-setup.tsx
Normal file
50
templates/provider-setup.tsx
Normal 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' }
|
||||
* }}
|
||||
* />
|
||||
*/
|
||||
72
templates/query-client-config.ts
Normal file
72
templates/query-client-config.ts
Normal 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
|
||||
*/
|
||||
214
templates/use-infinite-query.tsx
Normal file
214
templates/use-infinite-query.tsx
Normal 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
|
||||
*/
|
||||
201
templates/use-mutation-basic.tsx
Normal file
201
templates/use-mutation-basic.tsx
Normal 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
|
||||
*/
|
||||
234
templates/use-mutation-optimistic.tsx
Normal file
234
templates/use-mutation-optimistic.tsx
Normal 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
|
||||
*/
|
||||
119
templates/use-query-basic.tsx
Normal file
119
templates/use-query-basic.tsx
Normal 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
|
||||
*/
|
||||
Reference in New Issue
Block a user