Skip to main content

Advanced Features & Optimization

Learn advanced techniques for building performant, scalable server state management with Zustic Query.

Caching Architecture

Zustic Query uses an intelligent time-based caching system where each endpoint maintains its own cache expiration timer, allowing fine-grained control over data freshness and network efficiency.

Cache Flow & Behavior

The caching mechanism follows a predictable lifecycle:

const api = createApi({
baseQuery: myBaseQuery,
cacheTimeout: 5 * 60 * 1000, // Cache for 5 minutes
endpoints: (builder) => ({
getUsers: builder.query({
query: () => ({ url: '/users' })
})
})
})

// First call: Network request, store in cache
const { data: users1 } = useGetUsersQuery()

// Within 5 minutes: Return cached data instantly
const { data: users2 } = useGetUsersQuery()

// After 5 minutes: Fetch fresh data
const { data: users3 } = useGetUsersQuery()

// Manual refetch: Bypass cache immediately
const { reFetch } = useGetUsersQuery()
reFetch() // Always fetches fresh

Cache Configuration Strategies

Real-Time Data (Short Cache)

For frequently changing data, use a short cache window:

const api = createApi({
baseQuery: myBaseQuery,
cacheTimeout: 1 * 60 * 1000, // 1 minute
endpoints: (builder) => ({
getLiveStats: builder.query({
query: () => ({ url: '/stats' })
})
})
})

Stable Data (Long Cache)

For reference data that rarely changes, use extended cache durations:

const api = createApi({
baseQuery: myBaseQuery,
cacheTimeout: 30 * 60 * 1000, // 30 minutes
endpoints: (builder) => ({
getCountries: builder.query({
query: () => ({ url: '/countries' })
})
})
})

Always Fresh (No Cache)

For data that must always be current, disable caching entirely:

const api = createApi({
baseQuery: myBaseQuery,
cacheTimeout: 0, // Disable cache
endpoints: (builder) => ({
getRandomNumber: builder.query({
query: () => ({ url: '/random' })
})
})
})

Manual Cache Invalidation

Use reFetch() to bypass the cache and retrieve fresh data immediately. This is essential for operations like user-initiated refreshes or after data modifications.

Refresh Button Example

export function UsersList() {
const { data, reFetch, isLoading } = useGetUsersQuery()

const handleRefresh = () => {
reFetch() // Bypass cache, fetch fresh data
}

return (
<div>
<button onClick={handleRefresh} disabled={isLoading}>
{isLoading ? 'Refreshing...' : ' Refresh'}
</button>
<ul>
{data?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
</div>
)
}

Data Transformation

Transform API responses into application-specific formats, enabling clean separation between server contracts and application logic.

Response Normalization

interface ApiUser {
id: number
first_name: string
last_name: string
created_at: string
}

interface AppUser {
id: number
fullName: string
joinDate: Date
}

endpoints: (builder) => ({
getUser: builder.query({
query: (id) => ({ url: `/users/${id}` }),

// Transform API format to app format
transformResponse: (data: ApiUser): AppUser => {
return {
id: data.id,
fullName: `${data.first_name} ${data.last_name}`,
joinDate: new Date(data.created_at)
}
}
})
})

// Component receives transformed data
export function UserDetail({ userId }: { userId: number }) {
const { data: user } = useGetUserQuery(userId)

return (
<div>
<h1>{user?.fullName}</h1>
<p>Joined: {user?.joinDate.toLocaleDateString()}</p>
</div>
)
}

Error Handling & Recovery

Implement robust error handling with transformation and automatic retry strategies.

Error Normalization

endpoints: (builder) => ({
getUser: builder.query({
query: (id) => ({ url: `/users/${id}` }),

// Normalize error messages
transformError: (error: string) => {
if (error.includes('404')) return 'User not found'
if (error.includes('401')) return 'Unauthorized'
if (error.includes('500')) return 'Server error'
return 'An error occurred'
}
})
})

Automatic Retry with Exponential Backoff

Improve reliability by automatically retrying failed requests with progressive delays:

const retryMiddleware = async (ctx, next) => {
let lastError

// Try up to 3 times
for (let i = 0; i < 3; i++) {
const result = await next()

if (!result.error) {
return result
}

lastError = result.error

// Exponential backoff: 1s, 2s, 4s
if (i < 2) {
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000))
}
}

return { error: lastError }
}

const api = createApi({
baseQuery: myBaseQuery,
middlewares: [retryMiddleware],
endpoints: (builder) => ({
getUser: builder.query({
query: (id) => ({ url: `/users/${id}` })
})
})
})

Sequential Data Dependencies

Implement dependent query patterns where subsequent requests only execute after prerequisite data is loaded, preventing unnecessary network overhead.

Multi-Step Data Loading

export function UserPosts({ userId }: { userId: number }) {
// First query: fetch user
const { data: user } = useGetUserQuery(userId)

// Second query: depends on user being loaded
const { data: posts } = useGetUserPostsQuery(user?.id ?? 0, {
skip: !user // Skip until user is loaded
})

return (
<div>
<h1>{user?.name}</h1>
{posts?.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}

Real-Time Data Updates

Implement polling patterns for data that requires frequent refresh cycles, such as live statistics or status feeds.

Polling Implementation

import { useEffect } from 'react'

export function LiveStats() {
const { data: stats, reFetch } = useGetStatsQuery()

// Poll every 5 seconds
useEffect(() => {
const interval = setInterval(() => {
reFetch()
}, 5000)

return () => clearInterval(interval)
}, [reFetch])

return <div>Count: {stats?.count}</div>
}

Optimized Network Requests

Reduce network overhead and improve performance by batching multiple individual requests into single batch operations.

Batching Multiple Resources

// Problem: Multiple individual requests
export function Users() {
const { data: user1 } = useGetUserQuery(1)
const { data: user2 } = useGetUserQuery(2)
const { data: user3 } = useGetUserQuery(3)
// 3 separate requests
}

// Solution: Batch endpoint
const api = createApi({
baseQuery: myBaseQuery,
endpoints: (builder) => ({
getUsersBatch: builder.query({
query: (ids: number[]) => ({
url: '/users/batch',
method: 'POST',
body: { ids }
})
})
})
})

export function Users() {
const { data: users } = useGetUsersBatchQuery([1, 2, 3])
// 1 request for all users
}

Computational Memoization

Cache expensive computations to prevent redundant calculations across re-renders, improving application responsiveness and memory efficiency.

Memoizing Derived State

import { useMemo } from 'react'

export function UsersList() {
const { data: users } = useGetUsersQuery()

// Memoize expensive computations
const sortedUsers = useMemo(() => {
return users?.sort((a, b) => a.name.localeCompare(b.name)) || []
}, [users])

const usersByRole = useMemo(() => {
return users?.reduce((acc, user) => {
if (!acc[user.role]) acc[user.role] = []
acc[user.role].push(user)
return acc
}, {} as Record<string, any>) || {}
}, [users])

return <UserTable sorted={sortedUsers} byRole={usersByRole} />
}

Conditional Query Execution

Control which queries execute based on runtime conditions, permissions, or feature flags, optimizing resource usage and enabling progressive feature rollout.

Permission & Feature-Based Loading

export function Dashboard() {
// Only fetch if admin
const isAdmin = useIsAdmin()

const { data: analytics } = useGetAnalyticsQuery(undefined, {
skip: !isAdmin
})

// A/B testing
const useNewUI = useFeatureFlag('new-ui')

const { data: legacyData } = useGetPostsQuery(undefined, {
skip: useNewUI
})

const { data: modernData } = useGetPostsV2Query(undefined, {
skip: !useNewUI
})

return <Dashboard data={modernData || legacyData} analytics={analytics} />
}

Best Practices & Anti-Patterns

Follow these patterns to build robust, performant applications.

  • Use skip option for conditional queries
  • Call reFetch() to manually refetch when needed
  • Transform responses for your app's data format
  • Use appropriate cacheTimeout values
  • Handle errors in component UI

Anti-Patterns to Avoid

  • Don't call hooks conditionally (use skip instead)
  • Don't create API instances inside components
  • Don't forget to handle loading states
  • Don't pass undefined as query arguments
  • Don't ignore error states in UI

Cache Management Utilities

Zustic Query provides powerful utility functions for advanced cache manipulation, enabling optimistic updates, tag-based invalidation, and programmatic cache control.

updateQueryData - Optimistic Updates

Update cached query data programmatically without refetching. Perfect for optimistic updates where you update the cache immediately while mutations are in flight.

/**
* Updates cached query data for a specific endpoint and arguments.
*
* Useful for optimistic updates or manual cache manipulation.
* The updater function receives the current data and should return the updated data.
*/
api.utils.updateQueryData(key: string, arg: any, updater: (data: any) => any): void

Optimistic Update Example

export function UpdateUserEmail() {
const [email, setEmail] = useState('')
const { mutate: updateUser } = useUpdateUserMutation()

const handleSubmit = async () => {
try {
// Optimistically update cache
api.utils.updateQueryData('getUser', { id: 1 }, (draft) => ({
...draft,
email: email
}))

// Then mutate on server
const result = await updateUser({ id: 1, email })

setEmail('')
} catch (error) {
console.error('Failed to update:', error)
// Cache will be refetched on error
}
}

return (
<div>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="New email"
/>
<button onClick={handleSubmit}>Update Email</button>
</div>
)
}

Bulk Cache Operations

Transform entire cached datasets with complex logic:

// Remove user from cached list
api.utils.updateQueryData('getUsers', undefined, (draft) => {
return draft.filter(user => user.id !== userId)
})

// Sort cached users alphabetically
api.utils.updateQueryData('getUsers', undefined, (draft) => {
draft.sort((a, b) => a.name.localeCompare(b.name))
return draft
})

// Add new item to cached list
api.utils.updateQueryData('getUsers', undefined, (draft) => {
draft.push(newUser)
return draft
})

// Map and transform cached data
api.utils.updateQueryData('getUserPosts', { userId: 1 }, (draft) => {
return draft.map(post => ({
...post,
edited: true,
updatedAt: new Date().toISOString()
}))
})

invalidateTags - Tag-Based Cache Invalidation

Invalidate cached queries by tag names. This is essential after mutations when you want to refresh all related data without knowing specific cache keys.

/**
* Invalidates cached queries by tag names.
*
* Clears the cache for all endpoints whose providesTags match the provided tags.
* Supports both simple string tags and object tags with specific IDs.
*/
api.utils.invalidateTags(tags?: (string | {type: string; id?: string | number})[]): void
export function CreateUserForm() {
const [name, setName] = useState('')
const { mutate: createUser } = useCreateUserMutation()

const handleSubmit = async () => {
try {
const newUser = await createUser({ name })

// Invalidate all 'users' related cache
// This triggers refetch for all queries that provide 'users' tag
api.utils.invalidateTags(['users'])

setName('')
} catch (error) {
console.error('Failed to create user:', error)
}
}

return (
<form onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="User name"
/>
<button type="submit">Create User</button>
</form>
)
}

Invalidate Specific Items with ID Tags

export function DeleteUserButton({ userId }: { userId: number }) {
const { mutate: deleteUser } = useDeleteUserMutation()

const handleDelete = async () => {
try {
await deleteUser(userId)

// Invalidate the specific user and all their posts
api.utils.invalidateTags([
{ type: 'users', id: userId },
{ type: 'posts', id: userId }
])
} catch (error) {
console.error('Failed to delete user:', error)
}
}

return <button onClick={handleDelete}>Delete User</button>
}

Multiple Tag Invalidation After Complex Mutations

export function TransferOwnershipForm() {
const { mutate: transferOwnership } = useTransferOwnershipMutation()

const handleTransfer = async (postId: number, newOwnerId: number) => {
try {
await transferOwnership({ postId, newOwnerId })

// Invalidate multiple related caches
api.utils.invalidateTags([
{ type: 'posts', id: postId },
{ type: 'userPosts', id: newOwnerId },
'posts' // Refresh all posts
])
} catch (error) {
console.error('Failed to transfer:', error)
}
}

return (
// ... form UI
)
}

resetApiState - Full Cache Reset

Completely reset the API state by clearing all cached data and refetching active queries. Useful for scenarios like user logout or resetting application state.

/**
* Resets the entire API state by clearing the cache for all queries.
*
* This function iterates through all cached queries and clears their cache,
* which effectively resets all cached data. Useful for user logout or app reset.
*/
api.utils.resetApiState(): void

Reset on Logout

export function LogoutButton() {
const handleLogout = async () => {
try {
// Clear auth token from storage
localStorage.removeItem('authToken')

// Reset entire API state
// Clears all sensitive user data from cache
api.utils.resetApiState()

// Redirect to login
window.location.href = '/login'
} catch (error) {
console.error('Logout failed:', error)
}
}

return (
<button onClick={handleLogout} className="logout-btn">
Logout
</button>
)
}

Reset on Permission Change

export function PermissionGuard({ requiredPermission, children }: any) {
const [hasPermission, setHasPermission] = useState(false)

useEffect(() => {
const checkPermission = async () => {
try {
const result = await api.getPermissions()
const allowed = result.permissions.includes(requiredPermission)

if (!allowed) {
// User lost permission - clear all cached data
api.utils.resetApiState()
setHasPermission(false)
return
}

setHasPermission(true)
} catch (error) {
api.utils.resetApiState()
setHasPermission(false)
}
}

checkPermission()
}, [requiredPermission])

return hasPermission ? children : <AccessDenied />
}

refetchQuery - Single Query Refresh

Manually refetch a specific query, bypassing cache and forcing fresh data. Useful for explicit refresh buttons or after certain user actions.

/**
* Clears cache and refetches a specific query by endpoint key and arguments.
*
* Useful when you want to refresh a single query without affecting others.
* Always bypasses cache and fetches fresh data.
*/
api.utils.refetchQuery(key: string, arg: any): void

Manual Refresh Button

export function UsersList() {
const { data: users, isLoading } = useGetUsersQuery()

const handleRefresh = () => {
// Refetch only this specific query
api.utils.refetchQuery('getUsers', undefined)
}

return (
<div>
<button onClick={handleRefresh} disabled={isLoading}>
{isLoading ? 'Refreshing...' : '🔄 Refresh Users'}
</button>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}

Selective Query Refresh

export function Dashboard() {
const { data: stats } = useGetStatsQuery()
const { data: users } = useGetUsersQuery()

const handleStatsRefresh = () => {
// Only refresh stats, don't touch users cache
api.utils.refetchQuery('getStats', undefined)
}

const handleUsersRefresh = () => {
// Only refresh users with specific filter
api.utils.refetchQuery('getUsers', { role: 'admin' })
}

return (
<div>
<section>
<button onClick={handleStatsRefresh}>Refresh Stats</button>
<Stats data={stats} />
</section>
<section>
<button onClick={handleUsersRefresh}>Refresh Admin Users</button>
<UsersList data={users} />
</section>
</div>
)
}

Conditional Auto-Refresh

export function RealTimeStats() {
const { data: stats, isLoading } = useGetStatsQuery()

// Auto-refresh specific query every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
api.utils.refetchQuery('getStats', undefined)
}, 30 * 1000)

return () => clearInterval(interval)
}, [])

return (
<div>
<div>Last updated: {stats?.timestamp}</div>
<Stats data={stats} loading={isLoading} />
</div>
)
}

Combining Multiple Utilities

These utilities work together for powerful cache management patterns:

export function ComplexMutationFlow() {
const { mutate: createPost } = useCreatePostMutation()

const handleCreatePost = async (title: string, content: string) => {
try {
// 1. Optimistically update lists
api.utils.updateQueryData('getPosts', undefined, (draft) => [
...draft,
{ id: 'tmp', title, content, status: 'pending' }
])

// 2. Perform mutation
const post = await createPost({ title, content })

// 3. Clean up optimistic data and refetch
api.utils.refetchQuery('getPosts', undefined)

// 4. Invalidate related caches
api.utils.invalidateTags([
{ type: 'posts', id: post.id },
'postCount'
])
} catch (error) {
// On error, invalidate to get fresh data
api.utils.invalidateTags(['posts'])
}
}

return (
// ... form UI
)
}