@mycuppa/data
The @mycuppa/data package provides a powerful data fetching and caching system inspired by React Query, with automatic request deduplication, cache invalidation, and retry logic.
Installation
npm install @mycuppa/data react react-dom
# or
pnpm add @mycuppa/data react react-dom
Quick Start
import { createQueryClient, setQueryClient, useQuery } from '@mycuppa/data'
// Create and configure the query client once at app initialization
const queryClient = createQueryClient()
setQueryClient(queryClient)
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: 'users',
queryFn: async () => {
const response = await fetch('/api/users')
return response.json()
},
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Core Concepts
QueryClient
The QueryClient manages your application's data cache and fetching logic. Create one instance and share it across your app.
import { createQueryClient, setQueryClient } from '@mycuppa/data'
// Create the client
const queryClient = createQueryClient()
// Set it as the global client
setQueryClient(queryClient)
Query Keys
Query keys uniquely identify queries in the cache. They can be strings or arrays for complex scenarios:
// Simple string key
queryKey: 'users'
// Array key for dynamic queries
queryKey: ['user', userId]
// Complex key with filters
queryKey: ['users', { role: 'admin', status: 'active' }]
useQuery Hook
The useQuery hook fetches data and automatically manages loading, error, and success states.
Basic Usage
import { useQuery } from '@mycuppa/data'
function UserProfile({ userId }) {
const { data, isLoading, error, isSuccess } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to fetch user')
return response.json()
},
})
if (isLoading) return <div>Loading user...</div>
if (error) return <div>Error: {error.message}</div>
if (isSuccess) return <div>Hello, {data.name}!</div>
return null
}
Query Options
useQuery({
// Required: unique identifier for the query
queryKey: 'products',
// Required: function that returns a promise with the data
queryFn: async () => {
const response = await fetch('/api/products')
return response.json()
},
// Time before cached data is considered stale (default: 0)
staleTime: 5 * 60 * 1000, // 5 minutes
// Time before inactive cache is garbage collected (default: 5 minutes)
cacheTime: 10 * 60 * 1000, // 10 minutes
// Number of retry attempts on failure (default: 3)
retry: 3,
// Delay before first retry in ms (default: 1000)
retryDelay: 1000,
// Refetch when window regains focus (default: true)
refetchOnWindowFocus: true,
// Refetch when component mounts (default: true)
refetchOnMount: true,
// Enable or disable the query (default: true)
enabled: true,
// Called when query succeeds
onSuccess: (data) => {
console.log('Data fetched:', data)
},
// Called when query fails
onError: (error) => {
console.error('Query failed:', error)
},
})
Conditional Queries
Use the enabled option to conditionally fetch data:
function UserOrders({ userId }) {
// Only fetch orders if userId is provided
const { data, isLoading } = useQuery({
queryKey: ['orders', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}/orders`)
return response.json()
},
enabled: !!userId, // Only fetch when userId exists
})
if (!userId) return <div>Select a user to view orders</div>
if (isLoading) return <div>Loading orders...</div>
return <div>{data.length} orders found</div>
}
Manual Refetch
function ProductList() {
const { data, refetch, isFetching } = useQuery({
queryKey: 'products',
queryFn: fetchProducts,
})
return (
<div>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'Refreshing...' : 'Refresh Products'}
</button>
<ul>
{data?.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
)
}
useMutation Hook
The useMutation hook handles create, update, and delete operations.
Basic Usage
import { useMutation } from '@mycuppa/data'
function CreateUserForm() {
const { mutate, isLoading, error, isSuccess } = useMutation({
mutationFn: async (newUser) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
})
return response.json()
},
onSuccess: (data) => {
console.log('User created:', data)
},
})
const handleSubmit = (e) => {
e.preventDefault()
const formData = new FormData(e.target)
mutate({
name: formData.get('name'),
email: formData.get('email'),
})
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create User'}
</button>
{error && <div className="error">{error.message}</div>}
{isSuccess && <div className="success">User created successfully!</div>}
</form>
)
}
Mutation Options
useMutation({
// Required: function that performs the mutation
mutationFn: async (variables) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(variables),
})
return response.json()
},
// Called before mutation starts (for optimistic updates)
onMutate: async (variables) => {
console.log('About to create:', variables)
// Return context for rollback
return { previousData: getCurrentData() }
},
// Called when mutation succeeds
onSuccess: (data, variables, context) => {
console.log('Created:', data)
},
// Called when mutation fails
onError: (error, variables, context) => {
console.error('Failed:', error)
// Rollback using context
},
// Called when mutation completes (success or error)
onSettled: (data, error, variables, context) => {
console.log('Mutation completed')
},
// Number of retry attempts (default: 0)
retry: 2,
// Delay before first retry (default: 1000)
retryDelay: 1000,
})
Async Mutations
Use mutateAsync for promise-based workflows:
function UpdateUserForm({ userId }) {
const { mutateAsync, isLoading } = useMutation({
mutationFn: async (updates) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
})
return response.json()
},
})
const handleUpdate = async (updates) => {
try {
const result = await mutateAsync(updates)
console.log('Update successful:', result)
// Navigate or show success message
} catch (error) {
console.error('Update failed:', error)
// Handle error
}
}
return <button onClick={() => handleUpdate({ name: 'New Name' })}>Update</button>
}
Cache Management
Invalidate Queries
Mark queries as stale to trigger a refetch:
import { useQuery, useMutation } from '@mycuppa/data'
import { getQueryClient } from '@mycuppa/data'
function ProductManager() {
const queryClient = getQueryClient()
const { data: products } = useQuery({
queryKey: 'products',
queryFn: fetchProducts,
})
const { mutate: deleteProduct } = useMutation({
mutationFn: async (productId) => {
await fetch(`/api/products/${productId}`, { method: 'DELETE' })
},
onSuccess: () => {
// Invalidate and refetch products
queryClient.invalidateQueries('products')
},
})
return (
<ul>
{products?.map(product => (
<li key={product.id}>
{product.name}
<button onClick={() => deleteProduct(product.id)}>Delete</button>
</li>
))}
</ul>
)
}
Set Query Data
Manually update the cache:
const queryClient = getQueryClient()
// Update cache directly
queryClient.setQueryData('user', { id: 1, name: 'John' })
// Update based on previous data
const currentUser = queryClient.getQueryData('user')
queryClient.setQueryData('user', { ...currentUser, name: 'Jane' })
Remove Queries
Remove queries from the cache:
// Remove specific query
queryClient.removeQueries('users')
// Remove all queries
queryClient.removeQueries()
Request Deduplication
Multiple components requesting the same data simultaneously will share a single network request:
// Both components mount at the same time
function UserProfile({ userId }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <div>{data?.name}</div>
}
function UserAvatar({ userId }) {
// This will use the same request as UserProfile
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <img src={data?.avatar} />
}
// Only ONE network request is made for both components
Retry Logic
Failed queries automatically retry with exponential backoff:
useQuery({
queryKey: 'flaky-api',
queryFn: fetchFlakyData,
retry: 3, // Retry 3 times
retryDelay: 1000, // Start with 1 second delay
})
// Retry delays: 1s, 2s, 4s (exponential backoff)
// If all retries fail, query enters error state
Optimistic Updates
Update the UI immediately before the server confirms:
function TodoList() {
const queryClient = getQueryClient()
const { data: todos } = useQuery({
queryKey: 'todos',
queryFn: fetchTodos,
})
const { mutate: addTodo } = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
// Cancel outgoing queries
await queryClient.cancelQueries('todos')
// Save previous value for rollback
const previousTodos = queryClient.getQueryData('todos')
// Optimistically update cache
queryClient.setQueryData('todos', (old) => [...old, newTodo])
// Return context for rollback
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData('todos', context.previousTodos)
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries('todos')
},
})
return (
<div>
<ul>
{todos?.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button onClick={() => addTodo({ title: 'New Todo' })}>Add Todo</button>
</div>
)
}
Best Practices
-
Use descriptive query keys - Make keys specific and predictable
// Good queryKey: ['user', userId] queryKey: ['posts', { status: 'published', author: userId }] // Avoid queryKey: 'data' queryKey: 'all' -
Set appropriate staleTime - Reduce unnecessary refetches
// Static data - rarely changes staleTime: Infinity // Dynamic data - changes frequently staleTime: 0 // Semi-static data staleTime: 5 * 60 * 1000 // 5 minutes -
Invalidate after mutations - Keep cache synchronized
onSuccess: () => { queryClient.invalidateQueries('users') } -
Use TypeScript - Get full type safety
interface User { id: string name: string email: string } const { data } = useQuery<User>({ queryKey: 'user', queryFn: fetchUser, }) // data is typed as User | undefined -
Handle loading and error states - Provide good UX
if (isLoading) return <Spinner /> if (error) return <ErrorMessage error={error} /> if (!data) return <EmptyState /> -
Disable queries conditionally - Avoid unnecessary requests
enabled: !!userId && isAuthenticated
API Reference
createQueryClient()
Creates a new QueryClient instance.
Returns: QueryClient
setQueryClient(client)
Sets the global query client.
Parameters:
client: QueryClient- The client instance
useQuery(options)
Hook for data fetching with caching.
Parameters:
queryKey: QueryKey- Unique identifierqueryFn: () => Promise<TData>- Fetch functionstaleTime?: number- Stale time in ms (default: 0)cacheTime?: number- Cache time in ms (default: 300000)retry?: number- Retry attempts (default: 3)retryDelay?: number- Retry delay in ms (default: 1000)refetchOnWindowFocus?: boolean- Refetch on focus (default: true)refetchOnMount?: boolean- Refetch on mount (default: true)enabled?: boolean- Enable query (default: true)onSuccess?: (data) => void- Success callbackonError?: (error) => void- Error callback
Returns:
data: TData | undefined- Query dataerror: Error | null- Query errorisLoading: boolean- First fetch in progressisFetching: boolean- Any fetch in progressisSuccess: boolean- Query succeededisError: boolean- Query failedisIdle: boolean- Query is disabledstatus: QueryStatus- Current statusrefetch: () => Promise<void>- Manual refetch
useMutation(options)
Hook for data mutations (create, update, delete).
Parameters:
mutationFn: (variables) => Promise<TData>- Mutation functiononMutate?: (variables) => Promise<unknown>- Before mutationonSuccess?: (data, variables, context) => void- Success callbackonError?: (error, variables, context) => void- Error callbackonSettled?: (data, error, variables, context) => void- Finally callbackretry?: number- Retry attempts (default: 0)retryDelay?: number- Retry delay in ms (default: 1000)
Returns:
data: TData | undefined- Mutation dataerror: Error | null- Mutation errorisLoading: boolean- Mutation in progressisSuccess: boolean- Mutation succeededisError: boolean- Mutation failedisIdle: boolean- No mutation calledstatus: QueryStatus- Current statusmutate: (variables) => void- Trigger mutationmutateAsync: (variables) => Promise<TData>- Async triggerreset: () => void- Reset state
QueryClient Methods
fetchQuery(options)- Fetch and cache querygetQueryState(queryKey)- Get query stategetQueryData(queryKey)- Get cached datasetQueryData(queryKey, data)- Set cached datainvalidateQueries(queryKey?)- Mark queries as staleremoveQueries(queryKey?)- Remove from cacherefetchQueries(queryKey?)- Force refetchclear()- Clear all cachegetCacheSize()- Get cache size
Examples
Pagination
function PostList() {
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
})
return (
<div>
{isLoading ? (
<Spinner />
) : (
<ul>
{data.posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<button onClick={() => setPage(p => p + 1)} disabled={!data?.hasMore}>
Next
</button>
</div>
)
}
Dependent Queries
function UserDetails({ userId }) {
// First query: fetch user
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// Second query: fetch user's posts (depends on user)
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user.id),
enabled: !!user, // Only fetch when user is loaded
})
return (
<div>
<h1>{user?.name}</h1>
<ul>
{posts?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
Search with Debounce
import { debounce } from '@mycuppa/core'
function SearchUsers() {
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
// Debounce search query
const debouncedSearch = useMemo(
() => debounce((value) => setDebouncedQuery(value), 300),
[]
)
const { data, isLoading } = useQuery({
queryKey: ['users', 'search', debouncedQuery],
queryFn: () => searchUsers(debouncedQuery),
enabled: debouncedQuery.length > 0,
})
return (
<div>
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value)
debouncedSearch(e.target.value)
}}
placeholder="Search users..."
/>
{isLoading && <Spinner />}
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}
Comparison to Traditional Approach
Traditional approach (PHP/jQuery style):
// Manual state management, no caching, no deduplication
let loading = false
let error = null
let users = []
async function loadUsers() {
loading = true
try {
const response = await fetch('/api/users')
users = await response.json()
} catch (e) {
error = e
} finally {
loading = false
}
}
With @mycuppa/data:
// Automatic state management, caching, deduplication
const { data: users, isLoading, error } = useQuery({
queryKey: 'users',
queryFn: async () => {
const response = await fetch('/api/users')
return response.json()
},
})
The Cuppa approach eliminates boilerplate, provides automatic caching, handles race conditions, and gives you a consistent pattern for all data fetching.