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 | undefinedHow 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 | undefinedA shared, optionally persistent cache returned by
{@link
createCache
}
.cache, key?: unknown[] | undefinedCustom 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 | undefinedA shared, optionally persistent cache returned by
{@link
createCache
}
.cache, key?: unknown[] | undefinedCustom 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.