Cache System
Declarative React Query-like caching with automatic invalidation
The Cache System provides declarative, automatic cache management inspired by React Query. Cache keys and invalidation rules are defined server-side, and React hooks handle everything automatically client-side.
Why Use the Cache System?
Traditional cache management is imperative and error-prone:
// ❌ Old way - manual and imperative
const updateUser = mutation({
handler: async (ctx, args) => {
const user = await ctx.db.update(args.id, args);
// Must manually invalidate cache
stream.invalidate(`users:${args.id}`, { tags: ['users'] });
return success(user);
},
});The new cache system is declarative and automatic:
// ✅ New way - declarative
const updateUser = mutation({
invalidate: [['users', '{id}'], ['users']], // Declared once
handler: async (ctx, args) => {
return success(await ctx.db.update(args.id, args));
},
});
// Cache is automatically invalidated after success!Overview
The cache system has two parts:
- Server-side - Define cache metadata in queries/mutations
- Client-side - React hooks handle caching automatically
Server-Side: Cache Metadata
Query Cache Metadata
Add cache metadata to queries:
import { defineContext, Result } from '@deessejs/functions';
import { z } from 'zod';
const { t, createAPI } = defineContext({
database: myDatabase,
});
const getUser = t.query({
name: 'getUser',
cacheKey: ['users', '{id}'], // Cache key template
staleTime: 60000, // Cache for 1 minute
gcTime: 300000, // Garbage collect after 5 minutes
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
const user = await ctx.database.find(args.id);
if (!user) {
return Result.failure(new Error("User not found"));
}
return Result.success(user);
},
});Cache Metadata Options:
| Option | Type | Description |
|---|---|---|
cacheKey | TemplateString[] | Cache key pattern with interpolation |
staleTime | number | Time in ms before data is considered stale |
gcTime | number | Time in ms before cache is garbage collected |
Cache Key Templates
Cache keys support template interpolation:
// Simple interpolation
cacheKey: ['users', '{id}']
// For { id: 123 } → ['users', '123']
// Multiple parameters
cacheKey: ['users', '{userId}', 'posts', '{postId}']
// For { userId: 1, postId: 2 } → ['users', '1', 'posts', '2']
// No parameters
cacheKey: ['users']
// Static key for listsMutation Cache Invalidation
Declare what to invalidate in mutations:
const updateUser = t.mutation({
name: 'updateUser',
invalidate: [
['users', '{id}'], // Invalidate this specific user
['users'], // Invalidate all users list
['user-feed', '{id}'], // Invalidate user's feed
],
args: z.object({
id: z.number(),
name: z.string(),
}),
handler: async (ctx, args) => {
const user = await ctx.database.update(args.id, args.name);
return Result.success(user);
},
});Cache is automatically invalidated after successful mutations!
Invalidation Patterns
// Exact match
invalidate: [['exact', 'users:123']]
// Prefix match
invalidate: [['prefix', 'users']]
// Pattern match
invalidate: [['pattern', 'users:*:settings']]
// Tags (if supported)
invalidate: [['tags', 'users']]
// Shorthand (defaults to exact match)
invalidate: [['users', '{id}']]Client-Side: React Hooks
useQuery()
Automatic caching with useQuery():
import { useQuery } from '@deessejs/functions/react';
function UserProfile({ userId }) {
const {
data,
isLoading,
error,
isFetching,
refetch,
} = useQuery(getUser, { id: userId });
if (isLoading) return <Spinner />;
if (error) return <Error>{error.message}</Error>;
return (
<div>
<h1>{data.name}</h1>
<p>Email: {data.email}</p>
</div>
);
}Cache behavior:
- Data is stored by
cacheKey:['users', '123'] - Marked stale after
staleTime(1 minute) - Garbage collected after
gcTime(5 minutes) - Auto-refetch on window focus (configurable)
useMutation()
Automatic cache invalidation with useMutation():
import { useMutation } from '@deessejs/functions/react';
function UpdateUserForm({ userId }) {
const update = useMutation(updateUser);
return (
<form onSubmit={(e) => {
e.preventDefault();
update.mutate({
id: userId,
name: e.target.name.value
});
}}>
<input type="text" name="name" />
<button disabled={update.isPending}>
{update.isPending ? 'Updating...' : 'Update'}
</button>
{update.error && <Error>{update.error.message}</Error>}
</form>
);
}Cache behavior:
- After successful mutation, cache is automatically invalidated
- Queries with matching keys are automatically refetched
- No manual cache management needed!
useQuery Options
const { data } = useQuery(getUser, { id: 123 }, {
enabled: true, // Enable/disable query
refetchOnWindowFocus: true, // Refetch on window focus
refetchOnReconnect: true, // Refetch on reconnect
staleTime: 30000, // Override staleTime
});useMutation Options
const update = useMutation(updateUser, {
onSuccess: (data) => {
console.log('Updated!', data);
},
onError: (error) => {
console.error('Failed:', error);
},
});Cache Utilities
Prefetching
Prefetch data before it's needed:
import { prefetchQuery } from '@deessejs/functions/react';
function UserLink({ userId }) {
const { data } = useQuery(getUser, { id: userId });
return (
<a
href={`/users/${userId}`}
onMouseEnter={() => prefetchQuery(getUser, { id: userId })}
>
{data?.name || 'Loading...'}
</a>
);
}Manual Invalidation
Manually invalidate cache (rarely needed):
import { invalidateQueries } from '@deessejs/functions/react';
function RefreshButton() {
return (
<button onClick={() => invalidateQueries({
queryKey: ['users']
})}>
Refresh All Users
</button>
);
}Optimistic Updates
Update UI immediately, rollback on error:
import { useMutation } from '@deessejs/functions/react';
function LikeButton({ postId }) {
const queryClient = useQueryClient();
const like = useMutation(likePost, {
onMutate: async ({ postId }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// Snapshot previous value
const previous = queryClient.getQueryData(getPost, { id: postId });
// Optimistically update
queryClient.setQueryData(getPost, { id: postId }, (old) => ({
...old,
liked: !old.liked,
likeCount: old.likeCount + 1,
}));
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(getPost, { id: postId }, context.previous);
},
});
return (
<button
onClick={() => like.mutate({ postId })}
disabled={like.isPending}
>
{like.isPending ? '...' : '❤️'}
</button>
);
}Advanced Patterns
Dependent Queries
Query depends on previous query result:
function UserSettings({ userId }) {
// First query
const { data: user } = useQuery(getUser, { id: userId });
// Second query depends on first
const { data: settings } = useQuery(
getUserSettings,
{ userId: user?.id }, // Only runs when user is loaded
{
enabled: !!user, // Disable until user exists
}
);
if (!user || !settings) return <Spinner />;
return <div>{/* ... */}</div>;
}Parallel Queries
Run multiple independent queries in parallel:
function Dashboard() {
const users = useQuery(listUsers, {});
const posts = useQuery(listPosts, {});
const stats = useQuery(getStats, {});
if (users.isLoading || posts.isLoading || stats.isLoading) {
return <Spinner />;
}
return (
<div>
<h1>{users.data.length} users</h1>
<h1>{posts.data.length} posts</h1>
<h1>{stats.data.views} views</h1>
</div>
);
}Pagination with Cache
const { t, createAPI } = defineContext({ database: myDatabase });
const listPosts = t.query({
cacheKey: ['posts'], // Shared base key
args: z.object({
page: z.number().default(1),
limit: z.number().default(20),
}),
handler: async (ctx, args) => {
const posts = await ctx.database.posts.findAll({
offset: (args.page - 1) * args.limit,
limit: args.limit,
});
return Result.success(posts);
},
});
function PostList() {
const [page, setPage] = useState(1);
const { data } = useQuery(listPosts, { page });
return (
<div>
{data?.map(post => <PostCard key={post.id} post={post} />)}
<button onClick={() => setPage(p => p + 1)}>Next</button>
</div>
);
}Cache Configuration
Global Query Defaults
Set default cache behavior:
// In your app setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60000, // 1 minute
gcTime: 300000, // 5 minutes
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: 3,
},
mutations: {
retry: 1,
},
},
});
function App({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Per-Query Configuration
Override defaults per query:
const getUser = t.query({
cacheKey: ['users', '{id}'],
staleTime: 300000, // 5 minutes (longer cache)
gcTime: 600000, // 10 minutes
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
return Result.success(await ctx.database.find(args.id));
},
});Best Practices
1. Use Specific Cache Keys
// ✅ Good - specific keys
cacheKey: ['users', '{id}']
cacheKey: ['posts', '{postId}', 'comments']
// ❌ Bad - generic keys
cacheKey: ['data']
cacheKey: ['item']2. Set Appropriate Stale Times
// ✅ Good - realistic stale times
const getUser = t.query({
cacheKey: ['users', '{id}'],
staleTime: 60000, // 1 minute - user data changes rarely
});
const getStockPrice = t.query({
cacheKey: ['stocks', '{symbol}'],
staleTime: 5000, // 5 seconds - stock prices change frequently
});
// ❌ Bad - unrealistic stale times
const getUser = t.query({
cacheKey: ['users', '{id}'],
staleTime: 0, // Always refetch - wasteful
});3. Invalidate Only What's Needed
// ✅ Good - targeted invalidation
const updateUser = t.mutation({
invalidate: [['users', '{id}']], // Only this user
handler: async (ctx, args) => {
return Result.success(await ctx.db.update(args.id));
},
});
// ❌ Bad - blanket invalidation
const updateUser = t.mutation({
invalidate: [['users']], // All users - wasteful
handler: async (ctx, args) => {
return Result.success(await ctx.db.update(args.id));
},
});4. Use Prefetching for Better UX
// ✅ Good - prefetch on hover
<Link
to={`/users/${userId}`}
onMouseEnter={() => prefetchQuery(getUser, { id: userId })}
>
User Profile
</Link>Comparison: Before vs After
Before: Manual Cache Management
// ❌ Old way - imperative and error-prone
const updateUser = mutation({
handler: async (ctx, args) => {
const user = await ctx.db.update(args.id, args);
// Must remember to invalidate
stream.invalidate(`users:${args.id}`, {
tags: ['users', `user:${args.id}`],
data: { userId: args.id },
});
return success(user);
},
});
// Client-side - manual subscriptions
useEffect(() => {
const unsub = stream.subscribe('users:123', (event) => {
if (event.type === 'invalidation') {
queryClient.invalidateQueries('users:123');
}
});
return () => unsub();
}, []);After: Declarative Cache System
// ✅ New way - declarative and automatic
const updateUser = t.mutation({
invalidate: [['users', '{id}']], // Declared once
handler: async (ctx, args) => {
// No manual invalidation needed!
return Result.success(await ctx.db.update(args.id, args));
},
});
// Client-side - everything automatic
const { data } = useQuery(getUser, { id: 123 });
const update = useMutation(updateUser); // Auto-invalidates!Type Safety
Full TypeScript support throughout:
import { useQuery, useMutation } from '@deessejs/functions/react';
// Types are inferred from query/mutation definitions
const { data }: User = useQuery(getUser, { id: 123 });
// data is typed as User
const { mutate } = useMutation(updateUser);
// mutate({ id: 123, name: 'Alice' }) // ✅ Type-checked
// mutate({ id: '123', name: 'Alice' }) // ❌ Type errorSee Also
- Queries & Mutations - Defining queries with cache metadata
- Context System - Managing shared state
- Result - Type-safe error handling
- Retry - Automatic retry logic