Skip to content
Oxide
Esc
navigateopen⌘Jpreview
On this page

Caching

Cache server-action results in the browser with tacho's query cache. Dedupe in-flight calls and invalidate after mutations.

Server actions always hit the wire. For reads you call repeatedly — lists, lookups, dashboards — wrap them in tacho’s query cache instead of adding state by hand.

Wrap an action

Import query from tacho and pass it any server action:

// @filename: app.ts
import { 
function query<A extends unknown[], T>(fn: (...args: A) => Promise<T>, opts?: QueryOptions): ((...args: A) => Promise<T>) & {
    invalidate(match?: (info: {
        args: A;
    }) => boolean): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
} from "tacho";
import {
const listTasks: () => Promise<{
    id: string;
}[]>
listTasks
} from "./tasks.server";
const
const loadTasks: (() => Promise<{
    id: string;
}[]>) & {
    invalidate(match?: ((info: {
        args: [];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTasks
=
query<[], {
    id: string;
}[]>(fn: () => Promise<{
    id: string;
}[]>, opts?: QueryOptions): (() => Promise<{
    id: string;
}[]>) & {
    invalidate(match?: ((info: {
        args: [];
    }) => boolean) | undefined): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
(
const listTasks: () => Promise<{
    id: string;
}[]>
listTasks
, { staleTime?: number | undefined
How long results stay fresh in ms. Default: forever, until invalidated.
staleTime
: 30_000 });
const
const tasks: {
    id: string;
}[]
tasks
= await
const loadTasks: () => Promise<{
    id: string;
}[]>
loadTasks
(); // first call runs the action
const
const again: {
    id: string;
}[]
again
= await
const loadTasks: () => Promise<{
    id: string;
}[]>
loadTasks
(); // served from cache, no request

The result is still callable exactly like the action. The default cache identity ties to this wrapper plus a stable hash of the arguments:

// @filename: app.ts
import { 
function query<A extends unknown[], T>(fn: (...args: A) => Promise<T>, opts?: QueryOptions): ((...args: A) => Promise<T>) & {
    invalidate(match?: (info: {
        args: A;
    }) => boolean): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
} from "tacho";
import {
const getTask: (id: string) => Promise<{
    id: string;
    text: string;
}>
getTask
} from "./tasks.server";
const
const loadTask: ((id: string) => Promise<{
    id: string;
    text: string;
}>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
=
query<[id: string], {
    id: string;
    text: string;
}>(fn: (id: string) => Promise<{
    id: string;
    text: string;
}>, opts?: QueryOptions): ((id: string) => Promise<{
    id: string;
    text: string;
}>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
(
const getTask: (id: string) => Promise<{
    id: string;
    text: string;
}>
getTask
);
await
const loadTask: (id: string) => Promise<{
    id: string;
    text: string;
}>
loadTask
("1"); // cached under its own entry
await
const loadTask: (id: string) => Promise<{
    id: string;
    text: string;
}>
loadTask
("2"); // separate entry

Options:

option
staleTime How long results stay fresh in ms. Default: until invalidated.
key Explicit key prefix controlling the invalidation scope.

Failures are never cached: a failed call retries on the next invoke. Identical in-flight calls dedupe into one request.

Invalidate

Every wrapper carries invalidate() and clear():

// @filename: app.ts
import { 
function query<A extends unknown[], T>(fn: (...args: A) => Promise<T>, opts?: QueryOptions): ((...args: A) => Promise<T>) & {
    invalidate(match?: (info: {
        args: A;
    }) => boolean): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
} from "tacho";
import { const getTask: (id: string) => Promise<unknown>getTask } from "./tasks.server"; const
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
=
query<[id: string], unknown>(fn: (id: string) => Promise<unknown>, opts?: QueryOptions): ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
(const getTask: (id: string) => Promise<unknown>getTask);
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
.
function invalidate(match?: ((info: {
    args: [id: string];
}) => boolean) | undefined): void
Drop cached entries; pass a predicate over the call args to filter.
invalidate
(); // drop every cached input of this function
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
.
function invalidate(match?: ((info: {
    args: [id: string];
}) => boolean) | undefined): void
Drop cached entries; pass a predicate over the call args to filter.
invalidate
(({ args: [id: string]args }) => args: [id: string]args[0] === "1"); // selective
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
.function clear(): voidclear();

Call them after mutations so stale reads don’t linger:

// @filename: app.ts
import { 
function query<A extends unknown[], T>(fn: (...args: A) => Promise<T>, opts?: QueryOptions): ((...args: A) => Promise<T>) & {
    invalidate(match?: (info: {
        args: A;
    }) => boolean): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
} from "tacho";
import { const getTask: (id: string) => Promise<unknown>getTask,
const renameTask: (i: {
    id: string;
    text: string;
}) => Promise<void>
renameTask
} from "./tasks.server";
const
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
=
query<[id: string], unknown>(fn: (id: string) => Promise<unknown>, opts?: QueryOptions): ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
(const getTask: (id: string) => Promise<unknown>getTask);
export async function
function save(props: {
    id: string;
    text: string;
}): Promise<void>
save
(
props: {
    id: string;
    text: string;
}
props
: { id: stringid: string; text: stringtext: string }) {
await
function renameTask(i: {
    id: string;
    text: string;
}): Promise<void>
renameTask
(
props: {
    id: string;
    text: string;
}
props
);
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
.
function invalidate(match?: ((info: {
    args: [id: string];
}) => boolean) | undefined): void
Drop cached entries; pass a predicate over the call args to filter.
invalidate
(({ args: [id: string]args }) => args: [id: string]args[0] ===
props: {
    id: string;
    text: string;
}
props
.id: stringid);
}

To bust across wrappers at once, give related wrappers a shared key and a shared cache via createCache (below), or just call each wrapper’s invalidate().

Share a cache: createCache

By default each wrapper owns its own in-memory cache. Pass a cache created with createCache to share one store across wrappers — invalidating on the cache handle hits every wrapper that uses it:

// @filename: app.ts
import { 
function createCache(opts?: {
    name?: string;
    driver?: CacheDriver;
}): CacheHandle
Creates a cache handle backed by your driver. Multiple surfaces can share one handle; give them explicit `key` prefixes so cross-surface invalidation stays meaningful. ```ts const cache = createCache({ name: "myapp", driver: localStorageDriver() }); const listTasks = query(listTasksRaw, { cache, key: ["tasks"] }); await cache.invalidate(["tasks"]); ```
createCache
,
function query<A extends unknown[], T>(fn: (...args: A) => Promise<T>, opts?: QueryOptions): ((...args: A) => Promise<T>) & {
    invalidate(match?: (info: {
        args: A;
    }) => boolean): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
} from "tacho";
import { const getTask: (id: string) => Promise<unknown>getTask, const listTasks: () => Promise<unknown>listTasks } from "./tasks.server"; const const cache: QueryCacheApicache =
function createCache(opts?: {
    name?: string;
    driver?: CacheDriver;
}): CacheHandle
Creates a cache handle backed by your driver. Multiple surfaces can share one handle; give them explicit `key` prefixes so cross-surface invalidation stays meaningful. ```ts const cache = createCache({ name: "myapp", driver: localStorageDriver() }); const listTasks = query(listTasksRaw, { cache, key: ["tasks"] }); await cache.invalidate(["tasks"]); ```
createCache
();
const
const loadTasks: (() => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTasks
=
query<[], unknown>(fn: () => Promise<unknown>, opts?: QueryOptions): (() => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [];
    }) => boolean) | undefined): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
(const listTasks: () => Promise<unknown>listTasks, { cache?: QueryCacheApi | undefined
A shared, optionally persistent cache returned by {@link createCache } .
cache
, key?: unknown[] | undefined
Custom cache key prefix. Default for RPC clients is the method path; default for wrapped functions is a session identity plus the arguments.
key
: ["tasks"] });
const
const loadTask: ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
loadTask
=
query<[id: string], unknown>(fn: (id: string) => Promise<unknown>, opts?: QueryOptions): ((id: string) => Promise<unknown>) & {
    invalidate(match?: ((info: {
        args: [id: string];
    }) => boolean) | undefined): void;
    clear(): void;
}
Memoizes an async function — tacho server action stubs included. ```ts import { query } from "tacho"; import { getUser } from "./users.server"; const getUserCached = query(getUser, { staleTime: 30_000 }); await getUserCached({ id: "1" }); // deduped + cached per distinct input await getUserCached.invalidate(); // drop every cached input of this function ``` The default cache identity ties to this wrapper instance plus a stable hash of the arguments. Pass `{ key: ["users"] }` to pin the invalidation scope explicitly. Streams bypass the cache; failures are never cached.
query
(const getTask: (id: string) => Promise<unknown>getTask, { cache?: QueryCacheApi | undefined
A shared, optionally persistent cache returned by {@link createCache } .
cache
, key?: unknown[] | undefined
Custom cache key prefix. Default for RPC clients is the method path; default for wrapped functions is a session identity plus the arguments.
key
: ["tasks"] });
await const cache: QueryCacheApicache.
function invalidate(match?: InvalidateMatch, opts?: {
    refetch?: boolean;
}): Promise<void>
Drop matching cache entries. With `{ refetch: true }`, re-run them from the wire.
invalidate
(["tasks"]); // drops both wrappers' entries

The generated virtual:oxide/client accepts the same handle, so wrappers and the client share one store:

import { client } from "virtual:oxide/client";
import { createCache, query } from "tacho";

const cache = createCache();
const loadTask = query(getTask, { cache, key: ["tasks"] });
await client.cache.invalidate(["tasks"], { refetch: true });

See tacho’s Query Cache docs for persistence drivers (localStorageDriver() and custom CacheDriver implementations).

Cached client surface

The generated virtual:oxide/client is a full tacho HTTP client, so it exposes .query() / .cache too (types there are generated, not checked):

import { client } from "virtual:oxide/client";

await client.query().test.ping(); // cached with method-path keys
await client.cache.invalidate();

Prefer the query(fn) wrapper for day-to-day code: it keeps imports local (./tasks.server) and typed.

Was this page helpful?