Functions

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:

  1. Server-side - Define cache metadata in queries/mutations
  2. 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:

OptionTypeDescription
cacheKeyTemplateString[]Cache key pattern with interpolation
staleTimenumberTime in ms before data is considered stale
gcTimenumberTime 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 lists

Mutation 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 error

See Also

On this page