Files
spacedrive/docs/react/ui/normalized-cache.mdx
Jamie Pine ddcefe2495 docs
2025-11-14 21:40:49 -08:00

412 lines
9.4 KiB
Plaintext

---
title: Normalized Cache
sidebarTitle: Normalized Cache
---
The `useNormalizedCache` hook provides real-time, event-driven cache updates for TanStack Query. It enables instant UI updates across all devices without manual refetching.
## Problem
Traditional data fetching has two issues:
1. **Stale Data** - Users see outdated data until the next refetch
2. **Cross-Device Sync** - Changes on Device A aren't immediately visible on Device B
## Solution
`useNormalizedCache` wraps TanStack Query and listens for WebSocket events from the Spacedrive daemon. When data changes:
1. Backend emits `ResourceChanged` event
2. Hook atomically updates TanStack Query cache
3. React re-renders with fresh data
4. User sees changes instantly
TanStack Query still handles its normal refetching logic (staleTime, cacheTime, etc.), but events provide instant updates.
## Architecture
```
┌─────────────┐ ┌──────────────┐
│ Device A │───── mutation ────│ Daemon │
└─────────────┘ │ │
│ emits event │
┌─────────────┐ │ │
│ Device B │──── WebSocket ────│ │
│ │ event └──────────────┘
│ useNormalized
│ Cache() │
│ ↓ update │
│ TanStack │
│ Query │
│ ↓ re-render│
│ Component │
└─────────────┘
```
## Basic Usage
### Simple List Query
```tsx
import { useNormalizedCache } from '@sd/ts-client';
function LocationsList() {
const { data: locations, isLoading } = useNormalizedCache({
wireMethod: 'query:locations.list',
input: {},
resourceType: 'location',
isGlobalList: true,
});
if (isLoading) return <Loader />;
return (
<div>
{locations?.map(location => (
<div key={location.id}>{location.name}</div>
))}
</div>
);
}
```
When a location is created, updated, or deleted:
1. Backend emits `ResourceChanged` event with `resource_type: "location"`
2. Hook updates the cache
3. Component re-renders with new data
### Filtered Query with Resource Filter
```tsx
function DirectoryListing({ path }: { path: string }) {
const { data: files } = useNormalizedCache({
wireMethod: 'query:files.directory_listing',
input: { path },
resourceType: 'file',
resourceFilter: (file) => {
// Only accept files whose parent path matches our directory
return file.parent_path === path;
},
});
return (
<div>
{files?.map(file => (
<FileItem key={file.id} file={file} />
))}
</div>
);
}
```
The `resourceFilter` ensures only relevant files are added to this query's cache.
## Hook Options
```tsx
interface UseNormalizedCacheOptions<I> {
// Wire method to call (e.g., "query:locations.list")
wireMethod: string;
// Input for the query
input: I;
// Resource type for event filtering (e.g., "location", "file")
resourceType: string;
// Whether query is enabled (default: true)
enabled?: boolean;
// Whether this is a global list that accepts all new items (default: false)
isGlobalList?: boolean;
// Filter function to check if resource belongs in this query
resourceFilter?: (resource: any) => boolean;
}
```
### When to use `isGlobalList`
Set `isGlobalList: true` when the query returns **all resources of that type**:
```tsx
// Global list - returns all locations
useNormalizedCache({
wireMethod: 'query:locations.list',
input: {},
resourceType: 'location',
isGlobalList: true, // New locations should be added to this list
});
// Not a global list - returns files in specific directory
useNormalizedCache({
wireMethod: 'query:files.directory_listing',
input: { path: '/photos' },
resourceType: 'file',
isGlobalList: false, // Don't add all new files, only those in /photos
});
```
### When to use `resourceFilter`
Use `resourceFilter` when you need to check if an incoming resource belongs in this specific query:
```tsx
useNormalizedCache({
wireMethod: 'query:files.directory_listing',
input: { path: currentPath },
resourceType: 'file',
resourceFilter: (file) => {
// Only accept files in this directory
return file.parent_path === currentPath;
},
});
```
## Event Types
The hook listens for three event types:
### ResourceChanged
Single resource updated or created.
```rust
ResourceChanged {
resource_type: "location",
resource: { id: 1, name: "Photos", ... }
}
```
### ResourceChangedBatch
Multiple resources updated or created at once (performance optimization).
```rust
ResourceChangedBatch {
resource_type: "file",
resources: [
{ id: 1, name: "photo1.jpg", ... },
{ id: 2, name: "photo2.jpg", ... },
]
}
```
### ResourceDeleted
Resource deleted.
```rust
ResourceDeleted {
resource_type: "location",
resource_id: 42
}
```
## Deep Merge Strategy
When a resource update arrives, the hook performs a **deep merge** that preserves non-null existing values:
```tsx
// Existing cache data
{
id: 1,
name: "Photos",
metadata: { size: 1024, created_at: "2024-01-01" }
}
// Incoming event (partial update)
{
id: 1,
name: "My Photos",
metadata: { size: 2048 }
}
// Result after deep merge
{
id: 1,
name: "My Photos", // Updated
metadata: {
size: 2048, // Updated
created_at: "2024-01-01" // Preserved!
}
}
```
This prevents losing data when the backend sends partial updates.
## Library Scoping
The hook automatically includes the current library ID in the query key:
```tsx
const libraryId = client.getCurrentLibraryId();
const queryKey = [wireMethod, libraryId, input];
```
This ensures:
1. Switching libraries triggers a refetch
2. Events are scoped to the current library
3. Different libraries don't interfere with each other
## Array vs Wrapped Responses
The hook handles both response formats:
**Direct array:**
```tsx
type Response = LocationInfo[];
```
**Wrapped response:**
```tsx
type Response = {
locations: LocationInfo[];
};
```
It automatically detects the array field and updates it correctly.
## Performance Characteristics
- **Event latency:** Less than 20ms from backend to UI update
- **Memory:** O(n) where n = number of active queries
- **CPU:** O(1) per event (direct cache update)
- **Network:** Zero - events come via existing WebSocket
## Best Practices
### Use for List Queries
**Good:**
```tsx
// List of resources that can change
const { data: locations } = useNormalizedCache({
wireMethod: 'query:locations.list',
input: {},
resourceType: 'location',
isGlobalList: true,
});
```
**Bad:**
```tsx
// Single static resource - use regular useQuery
const { data: config } = useNormalizedCache({
wireMethod: 'query:config.get',
input: {},
resourceType: 'config', // Events unlikely, overhead not worth it
});
```
### Provide Accurate Filters
**Good:**
```tsx
// Precise filter based on actual relationship
resourceFilter: (file) => file.parent_path === currentPath
```
**Bad:**
```tsx
// Vague filter that might let incorrect items through
resourceFilter: (file) => file.name.includes('photo')
```
### Combine with TanStack Query Options
```tsx
const { data } = useNormalizedCache({
wireMethod: 'query:files.directory_listing',
input: { path },
resourceType: 'file',
// TanStack Query options work normally
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: true,
});
```
## Debugging
### Check Event Flow
```tsx
useEffect(() => {
const handleEvent = (event: any) => {
console.log('Event received:', event);
};
const unsubscribe = client.on('spacedrive-event', handleEvent);
return unsubscribe;
}, []);
```
### Inspect Query Cache
```tsx
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
// View all queries
console.log(queryClient.getQueryCache().getAll());
// View specific query
const queryKey = ['query:locations.list', libraryId, {}];
console.log(queryClient.getQueryData(queryKey));
```
## Migration from useQuery
Replace:
```tsx
import { useQuery } from '@tanstack/react-query';
const { data } = useQuery({
queryKey: ['locations'],
queryFn: () => client.query('locations.list', {}),
});
```
With:
```tsx
import { useNormalizedCache } from '@sd/ts-client';
const { data } = useNormalizedCache({
wireMethod: 'query:locations.list',
input: {},
resourceType: 'location',
isGlobalList: true,
});
```
## Advanced: Content Addressing
For Content-addressed paths (CAS), the hook includes special logic:
```tsx
// Files with same content_id belong together even if paths differ
if (resource.sd_path?.Content?.content_id) {
const eventContentId = resource.sd_path.Content.content_id;
shouldAppend = existingFiles.some(f =>
f.content_identity?.uuid === eventContentId
);
}
```
This handles deduplication scenarios where the same file content exists at multiple paths.
## Summary
`useNormalizedCache` provides:
- **Real-time updates** via WebSocket events
- **Cross-device sync** out of the box
- **Zero refetch delay** for instant UX
- **TanStack Query compatibility** - all normal features work
- **Smart filtering** with `isGlobalList` and `resourceFilter`
- **Deep merge** to preserve data
- **Library scoping** automatically
Use it for any list query where data can change and you want instant updates across all clients.