Cache Management
This guide covers how the OSDK React cache system works and how to manually control it.
Why use @osdk/react?
@osdk/react gives you behavior that is hard to reproduce with a generic data layer like TanStack Query:
- Normalized object cache. Every
Todo:1is stored once. When an action edits it, every list, link, and detail view referencing it updates automatically. - Action-driven invalidation. Action responses specify which objects were added, modified, or deleted. Lists are re-evaluated against their
whereclauses without manual invalidation. - Optimistic updates with rollback.
$optimisticUpdatelayers your changes on top of the truth layer; on failure they're rolled back. - Real-time updates.
streamUpdates: truekeeps lists current via WebSocket without polling. - Function dependency tracking with
useOsdkFunction. PassdependsOn: [ObjectType]to refetch when any object of that type changes, ordependsOnObjects: [instance]to refetch only when specific instances change. The cache wires the dependencies so your function reruns automatically after relevant actions.
How the Cache Works
The OsdkProvider creates an ObservableClient that maintains a normalized cache of all queries and their results.
What Gets Cached
| Data Type | Cache Key Based On |
|---|---|
| Objects | Object type + primary key |
| Lists | Object type + where clause + orderBy |
| ObjectSets | Base object type + transforms (where, withProperties, pivotTo, set ops) |
| Links | Source object + link name + filters |
| Aggregations | Object type + where clause + aggregate definition (+ optional ObjectSet) |
| Function queries | Function apiName + canonicalized parameters |
Per-property security metadata is loaded alongside objects when $loadPropertySecurityMetadata: true is passed to useOsdkObject / useOsdkObjects.
Cache Sharing
- Same query = same cache entry: Two components calling
useOsdkObjects(Todo, { where: { isComplete: false } })share the same cached result - Different filters = different entries:
{ isComplete: false }and{ isComplete: true }create separate cache entries - Derived properties create variants: Adding
withPropertiescreates a separate cache entry
Object Normalization
Objects are stored once and shared across all queries that reference them:
useOsdkObjects(Todo) ─┐
├─► Todo:1 (single cached instance)
useOsdkObject(Todo, "1") ─┘
When Todo:1 is updated, all queries referencing it see the change immediately.
Automatic Cache Updates
After Actions
When an action completes, the server response specifies which objects were affected:
- Added objects: Fetched and added to cache, relevant lists re-evaluated
- Modified objects: Re-fetched, all queries containing them updated
- Deleted objects: Removed from cache and all lists
Optimistic Updates
With $optimisticUpdate:
- Optimistic layer created with your changes
- UI shows optimistic state (
isOptimistic: true) - On success: Server data replaces optimistic layer
- On failure: Optimistic layer removed, original state restored
Real-time Updates
With streamUpdates: true, the cache receives WebSocket updates and applies them automatically. Not available for queries that use pivotTo or withProperties.
When Manual Invalidation is Needed
Automatic updates don't cover:
- Data changed by an external system or external cache
- Data modified outside OSDK (direct API calls, background jobs)
Manual Cache Invalidation
The ObservableClient provides methods to manually invalidate cached data.
Invalidation Methods
Object Invalidation
| Method | Effect | Use Case |
|---|---|---|
invalidateObjects([obj1, obj2]) | Re-fetches specific objects | You know exactly which objects are stale |
invalidateObjectType(Todo) | Re-fetches all objects and lists of that type | External bulk update |
invalidateAll() | Re-fetches everything | Last resort |
Function Invalidation
| Method | Effect | Use Case |
|---|---|---|
invalidateFunction(queryDef, params) | Re-fetches a specific function query | You know which function call is stale |
invalidateFunction(queryDef) | Re-fetches ALL queries for that function | External change affecting all calls |
invalidateFunctionsByObject(apiName, pk) | Re-fetches functions depending on a specific object | Object changed outside action flow |
Usage
Get the ObservableClient with useObservableClient from any component below OsdkProvider:
import { getEmployeeMetrics, Todo } from "@my/osdk";
import { useObservableClient } from "@osdk/react";
const observableClient = useObservableClient();
// Invalidate specific objects
await observableClient.invalidateObjects([todo1, todo2]);
// Invalidate all data for a type
await observableClient.invalidateObjectType(Todo);
// Invalidate a specific function query
await observableClient.invalidateFunction(getEmployeeMetrics, {
departmentId: "sales",
});
// Invalidate ALL queries for a function
await observableClient.invalidateFunction(getEmployeeMetrics);
// Invalidate functions that depend on a specific object instance
// (functions called with dependsOnObjects containing this object)
await observableClient.invalidateFunctionsByObject("Employee", "emp-123");
// Invalidate everything (use sparingly)
await observableClient.invalidateAll();
Type Isolation
invalidateObjectType(Todo) only affects Todo-related data:
- Re-fetches all Todo objects
- Re-fetches all Todo lists
- Re-fetches links where Todo is the source type
- Does NOT affect other object types, even if linked to Todos
Function Invalidation Details
invalidateFunctionsByObject(apiName, primaryKey) invalidates function queries that were registered with dependsOnObjects containing the specified object:
// If useOsdkFunction was called with:
const { data } = useOsdkFunction(getEmployeeReport, {
params: { employeeId: employee.$primaryKey },
dependsOnObjects: [employee],
});
// You can invalidate it by calling:
await observableClient.invalidateFunctionsByObject(
"Employee",
employee.$primaryKey,
);
This is useful when you know a specific object has changed outside of the normal action flow.
Cache with Optimistic Updates
import { completeTodo, Todo } from "@my/osdk";
import { useOsdkAction, useOsdkObject } from "@osdk/react";
function TodoView({ todo }: { todo: Todo.OsdkInstance }) {
const { isOptimistic } = useOsdkObject(todo);
const { applyAction } = useOsdkAction(completeTodo);
const handleComplete = () => {
applyAction({
todo,
isComplete: true,
$optimisticUpdate: (ou) => {
ou.updateObject(todo.$clone({ isComplete: true }));
},
});
};
return (
<div>
{todo.title}
{isOptimistic && " (Saving...)"}
<button onClick={handleComplete}>Complete</button>
</div>
);
}
Dev-Mode Debugging
The observable client has dev-only debugging knobs on the OsdkProvider devMode prop. They have no effect in production builds, where the relevant code is stripped at build time.
The client's loggers default to the error level, which hides the debug tracing the observable layer emits. Raise the level to surface it:
<OsdkProvider client={client} devMode={{ logLevel: "debug" }}>
The cache internals can also log their behavior. refCounts logs cache-entry reference-count lifecycle (creation, live counts, time-to-live countdown, cleanup, finalization) and shortens the retention window so cleanup is observable sooner; cacheKeys logs cache-key canonicalization lookups:
<OsdkProvider client={client} devMode={{ debug: { refCounts: true, cacheKeys: true } }}>
If you are configuring the observable client directly, the same options are available as createObservableClient(client, extraUserAgents, { devMode: { logLevel: "debug", debug: { refCounts: true } } }).
Best Practices
- Reuse query parameters. Two components that pass identical
where/orderBy/withPropertiesshare one cache entry. Hoist the parameter object to a module-level constant or memoize it so React doesn't construct a new one each render. - Prefer object-level invalidation.
invalidateObjects([todo])is cheaper thaninvalidateObjectType(Todo), which is cheaper thaninvalidateAll(). Reach for the broadest only when you cannot identify the affected rows. - Let actions drive invalidation. If the change comes from an OSDK action, the cache updates automatically — do not call
invalidate*afterward. Manual invalidation is for changes that bypass the action flow (external system, scheduled job, direct REST call). - Use
dependsOn/dependsOnObjectson functions. Declare what your function reads so the cache can re-run it after relevant actions, instead of invalidating it manually. - Avoid second sources of truth. Don't copy OSDK data into Redux, TanStack Query, or local component state — you'll fight optimistic updates and lose normalization.
- Don't
invalidateAll()on mount. That defeats caching and triggers a full refetch every time the component tree mounts. If you need a fresh load, scope it to a specific type.