Skip to main content

One post tagged with "Zustand"

View all tags

Implementing Data Caching in Zustand Store

· 3 min read

Encountered this issue while building an AI Agent platform. Here's the root cause and solution.

TL;DR

Add lastFetchTime field and TTL constant to Zustand store. Check cache expiration before requesting. Implement effective data caching in ~10 lines of code, avoiding duplicate requests across pages.

Problem

MCP tools list is used by multiple pages (Agent Settings, Tools Marketplace, Chat tool selector). Each page entry triggers an API request:

GET /api/mcp-tools  → 200  (AgentSettingsPage)
GET /api/mcp-tools → 200 (McpToolsPage)
GET /api/mcp-tools → 200 (ChatPage tool selector)

Tools list rarely changes (admin configures manually), but frequent requests waste bandwidth and slow page loads.

Root Cause

Components call API directly without caching:

// ❌ No caching: requests on every mount
function McpToolsPage() {
const [tools, setTools] = useState([])

useEffect(() => {
mcpToolsApi.list().then(setTools)
}, [])

return <ToolList tools={tools} />
}

Problems:

  1. Each page requests independently — No global state sharing
  2. Repeated requests in short time — Triggered when user navigates between pages
  3. No refresh control — Re-fetches even when data unchanged

Solution

Zustand Store + TTL Cache

// src/stores/mcpToolsStore.ts
import { create } from 'zustand'
import { mcpToolsApi, type McpTool } from '@/services/api'

const CACHE_TTL = 10 * 60 * 1000 // 10 minutes

interface McpToolsState {
tools: McpTool[]
lastFetchTime: number | null
loading: boolean
error: string | null

fetchTools: (force?: boolean) => Promise<void>
clearError: () => void
}

export const useMcpToolsStore = create<McpToolsState>((set, get) => ({
tools: [],
lastFetchTime: null,
loading: false,
error: null,

fetchTools: async (force = false) => {
const { tools, lastFetchTime } = get()

// Has cache, not expired, not forced → skip
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // Cache hit
}
}

set({ loading: true, error: null })
try {
const data = await mcpToolsApi.list()
set({ tools: data, lastFetchTime: Date.now(), loading: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tools'
set({ error: message, loading: false })
}
},

clearError: () => set({ error: null }),
}))

Component Usage

// ✅ Using store cache
function McpToolsPage() {
const { tools, loading, fetchTools } = useMcpToolsStore()

useEffect(() => {
fetchTools() // Auto-checks cache
}, [fetchTools])

if (loading) return <Spinner />
return <ToolList tools={tools} />
}

// Force refresh
function RefreshButton() {
const { fetchTools } = useMcpToolsStore()
return <button onClick={() => fetchTools(true)}>Refresh</button>
}

Core Logic Explained

// Cache check logic
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // Cache valid, skip request
}
}
ConditionMeaning
tools.lengthHas data (empty array not valid cache)
lastFetchTimeRecorded last request time
!forceNot forced refresh
Date.now() - lastFetchTime < CACHE_TTLNot expired

Use Cases

ScenarioSuitableReason
Tool lists, config dictionaries✅ YesLow change frequency, shared across pages
User permissions, roles✅ YesRarely changes within session
Real-time data (messages, notifications)❌ NoNeeds latest state
Paginated lists❌ NoLarge data volume, complex caching strategy

Extension: Fine-grained Cache Control

interface CacheOptions {
ttl: number // Expiration time
staleWhileRevalidate: boolean // Return stale data while refreshing
}

// Background refresh after expiration, return cached data first
if (tools.length && lastFetchTime) {
const age = Date.now() - lastFetchTime
if (age < CACHE_TTL) {
return // Cache fresh
}
if (options.staleWhileRevalidate && age < CACHE_TTL * 2) {
// Cache stale but acceptable, background refresh
mcpToolsApi.list().then(data => set({ tools: data, lastFetchTime: Date.now() }))
return
}
}

Key Principles

  1. Set reasonable TTL — Based on data change frequency
  2. Provide force refresh — Users can manually get latest data
  3. Show loading on first fetch — Empty data shouldn't skip request

Interested in similar solutions? Contact us