MyCuppa

@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

  1. Use descriptive query keys - Make keys specific and predictable

    // Good
    queryKey: ['user', userId]
    queryKey: ['posts', { status: 'published', author: userId }]
    
    // Avoid
    queryKey: 'data'
    queryKey: 'all'
    
  2. 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
    
  3. Invalidate after mutations - Keep cache synchronized

    onSuccess: () => {
      queryClient.invalidateQueries('users')
    }
    
  4. 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
    
  5. Handle loading and error states - Provide good UX

    if (isLoading) return <Spinner />
    if (error) return <ErrorMessage error={error} />
    if (!data) return <EmptyState />
    
  6. 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 identifier
  • queryFn: () => Promise<TData> - Fetch function
  • staleTime?: 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 callback
  • onError?: (error) => void - Error callback

Returns:

  • data: TData | undefined - Query data
  • error: Error | null - Query error
  • isLoading: boolean - First fetch in progress
  • isFetching: boolean - Any fetch in progress
  • isSuccess: boolean - Query succeeded
  • isError: boolean - Query failed
  • isIdle: boolean - Query is disabled
  • status: QueryStatus - Current status
  • refetch: () => Promise<void> - Manual refetch

useMutation(options)

Hook for data mutations (create, update, delete).

Parameters:

  • mutationFn: (variables) => Promise<TData> - Mutation function
  • onMutate?: (variables) => Promise<unknown> - Before mutation
  • onSuccess?: (data, variables, context) => void - Success callback
  • onError?: (error, variables, context) => void - Error callback
  • onSettled?: (data, error, variables, context) => void - Finally callback
  • retry?: number - Retry attempts (default: 0)
  • retryDelay?: number - Retry delay in ms (default: 1000)

Returns:

  • data: TData | undefined - Mutation data
  • error: Error | null - Mutation error
  • isLoading: boolean - Mutation in progress
  • isSuccess: boolean - Mutation succeeded
  • isError: boolean - Mutation failed
  • isIdle: boolean - No mutation called
  • status: QueryStatus - Current status
  • mutate: (variables) => void - Trigger mutation
  • mutateAsync: (variables) => Promise<TData> - Async trigger
  • reset: () => void - Reset state

QueryClient Methods

  • fetchQuery(options) - Fetch and cache query
  • getQueryState(queryKey) - Get query state
  • getQueryData(queryKey) - Get cached data
  • setQueryData(queryKey, data) - Set cached data
  • invalidateQueries(queryKey?) - Mark queries as stale
  • removeQueries(queryKey?) - Remove from cache
  • refetchQueries(queryKey?) - Force refetch
  • clear() - Clear all cache
  • getCacheSize() - 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.