mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
634 lines
14 KiB
Plaintext
634 lines
14 KiB
Plaintext
---
|
|
title: React Hooks
|
|
sidebarTitle: Hooks
|
|
---
|
|
|
|
Spacedrive provides type-safe React hooks for data fetching, mutations, and event subscriptions. All types are auto-generated from Rust definitions.
|
|
|
|
## Data Fetching Hooks
|
|
|
|
### useCoreQuery
|
|
|
|
Type-safe hook for core-scoped queries (operations that don't require a library).
|
|
|
|
```tsx
|
|
import { useCoreQuery } from '@sd/interface';
|
|
|
|
function LibraryList() {
|
|
const { data: libraries, isLoading, error } = useCoreQuery({
|
|
type: 'libraries.list',
|
|
input: { include_stats: false },
|
|
});
|
|
|
|
if (isLoading) return <Loader />;
|
|
if (error) return <div>Error: {error.message}</div>;
|
|
|
|
return (
|
|
<div>
|
|
{libraries?.map(lib => (
|
|
<div key={lib.id}>{lib.name}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Features:**
|
|
- **Auto-generated types** - `type` and `input` are type-checked against Rust definitions
|
|
- **TanStack Query** - Full TanStack Query API available
|
|
- **Automatic caching** - Query results are cached by key
|
|
- **Type inference** - `data` type is automatically inferred
|
|
|
|
**Examples:**
|
|
|
|
```tsx
|
|
// Get node information
|
|
const { data: nodeInfo } = useCoreQuery({
|
|
type: 'node.info',
|
|
input: {},
|
|
});
|
|
|
|
// List all libraries with stats
|
|
const { data: libraries } = useCoreQuery({
|
|
type: 'libraries.list',
|
|
input: { include_stats: true },
|
|
});
|
|
|
|
// With TanStack Query options
|
|
const { data: libraries } = useCoreQuery(
|
|
{
|
|
type: 'libraries.list',
|
|
input: {},
|
|
},
|
|
{
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
refetchOnWindowFocus: false,
|
|
}
|
|
);
|
|
```
|
|
|
|
### useLibraryQuery
|
|
|
|
Type-safe hook for library-scoped queries (operations within a specific library).
|
|
|
|
```tsx
|
|
import { useLibraryQuery } from '@sd/interface';
|
|
|
|
function FileExplorer({ path }: { path: string }) {
|
|
const { data, isLoading } = useLibraryQuery({
|
|
type: 'files.directory_listing',
|
|
input: { path },
|
|
});
|
|
|
|
if (isLoading) return <Loader />;
|
|
|
|
return (
|
|
<div>
|
|
{data?.entries.map(entry => (
|
|
<div key={entry.id}>{entry.name}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Features:**
|
|
- **Automatic library scoping** - Uses current library ID from client
|
|
- **Library switching** - Automatically refetches when library changes
|
|
- **Type safety** - Input/output types inferred from operation
|
|
- **Disabled when no library** - Query is disabled if no library selected
|
|
|
|
**Examples:**
|
|
|
|
```tsx
|
|
// Get directory listing
|
|
const { data: files } = useLibraryQuery({
|
|
type: 'files.directory_listing',
|
|
input: { path: '/photos' },
|
|
});
|
|
|
|
// List all locations
|
|
const { data: locations } = useLibraryQuery({
|
|
type: 'locations.list',
|
|
input: {},
|
|
});
|
|
|
|
// Get file metadata
|
|
const { data: metadata } = useLibraryQuery({
|
|
type: 'files.get_metadata',
|
|
input: { path: '/photos/IMG_1234.jpg' },
|
|
});
|
|
|
|
// Search files
|
|
const { data: results } = useLibraryQuery({
|
|
type: 'files.search',
|
|
input: { query: 'vacation', filters: [] },
|
|
});
|
|
```
|
|
|
|
### useNormalizedCache
|
|
|
|
Event-driven cache updates for real-time sync across devices. See [Normalized Cache](/react/ui/normalized-cache) for full documentation.
|
|
|
|
```tsx
|
|
import { useNormalizedCache } from '@sd/interface';
|
|
|
|
function LocationsList() {
|
|
const { data: locations } = useNormalizedCache({
|
|
wireMethod: 'query:locations.list',
|
|
input: {},
|
|
resourceType: 'location',
|
|
isGlobalList: true,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
{locations?.map(location => (
|
|
<div key={location.id}>{location.name}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**When to use:**
|
|
- List queries that need instant updates across devices
|
|
- File listings that change frequently
|
|
- Real-time resource monitoring
|
|
|
|
## Mutation Hooks
|
|
|
|
### useCoreMutation
|
|
|
|
Type-safe hook for core-scoped mutations (operations that don't require a library).
|
|
|
|
```tsx
|
|
import { useCoreMutation } from '@sd/interface';
|
|
|
|
function CreateLibraryButton() {
|
|
const createLib = useCoreMutation('libraries.create');
|
|
|
|
const handleCreate = () => {
|
|
createLib.mutate(
|
|
{ name: 'My Library', path: null },
|
|
{
|
|
onSuccess: (library) => {
|
|
console.log('Created:', library.name);
|
|
},
|
|
onError: (error) => {
|
|
console.error('Failed:', error.message);
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
return (
|
|
<button onClick={handleCreate} disabled={createLib.isPending}>
|
|
{createLib.isPending ? 'Creating...' : 'Create Library'}
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Features:**
|
|
- **Type-safe input** - Mutation input is type-checked
|
|
- **Type-safe output** - Success callback receives typed result
|
|
- **Loading states** - `isPending`, `isSuccess`, `isError`
|
|
- **TanStack Query integration** - Full mutation API available
|
|
|
|
**Examples:**
|
|
|
|
```tsx
|
|
// Create library
|
|
const createLib = useCoreMutation('libraries.create');
|
|
createLib.mutate({ name: 'Photos', path: '/Users/me/Photos' });
|
|
|
|
// Delete library
|
|
const deleteLib = useCoreMutation('libraries.delete');
|
|
deleteLib.mutate({ id: '123' });
|
|
|
|
// Update node config
|
|
const updateConfig = useCoreMutation('node.update_config');
|
|
updateConfig.mutate({ theme: 'dark', port: 8080 });
|
|
```
|
|
|
|
### useLibraryMutation
|
|
|
|
Type-safe hook for library-scoped mutations (operations within a specific library).
|
|
|
|
```tsx
|
|
import { useLibraryMutation } from '@sd/interface';
|
|
|
|
function ApplyTagsButton({ fileIds, tagIds }: { fileIds: number[], tagIds: string[] }) {
|
|
const applyTags = useLibraryMutation('tags.apply');
|
|
|
|
return (
|
|
<button
|
|
onClick={() =>
|
|
applyTags.mutate(
|
|
{ entry_ids: fileIds, tag_ids: tagIds },
|
|
{
|
|
onSuccess: () => {
|
|
toast.success('Tags applied!');
|
|
},
|
|
}
|
|
)
|
|
}
|
|
disabled={applyTags.isPending}
|
|
>
|
|
Apply Tags
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Features:**
|
|
- **Automatic library scoping** - Uses current library ID
|
|
- **Type safety** - Input/output types inferred
|
|
- **Error handling** - Throws if no library selected
|
|
- **TanStack Query callbacks** - onSuccess, onError, onSettled
|
|
|
|
**Examples:**
|
|
|
|
```tsx
|
|
// Create location
|
|
const createLocation = useLibraryMutation('locations.create');
|
|
createLocation.mutate({ path: '/Users/me/Photos', index_mode: 'deep' });
|
|
|
|
// Delete files
|
|
const deleteFiles = useLibraryMutation('files.delete');
|
|
deleteFiles.mutate({ paths: ['/photo1.jpg', '/photo2.jpg'] });
|
|
|
|
// Apply tags
|
|
const applyTags = useLibraryMutation('tags.apply');
|
|
applyTags.mutate({ entry_ids: [1, 2, 3], tag_ids: ['tag-uuid'] });
|
|
|
|
// Move files
|
|
const moveFiles = useLibraryMutation('files.move');
|
|
moveFiles.mutate({ source: '/old/path', destination: '/new/path' });
|
|
```
|
|
|
|
## Event Hooks
|
|
|
|
### useEvent
|
|
|
|
Subscribe to specific Spacedrive events.
|
|
|
|
```tsx
|
|
import { useEvent } from '@sd/interface';
|
|
|
|
function JobProgress() {
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
useEvent('JobProgress', (event) => {
|
|
const jobProgress = event.JobProgress;
|
|
setProgress(jobProgress.progress_percentage);
|
|
});
|
|
|
|
return <ProgressBar value={progress} />;
|
|
}
|
|
```
|
|
|
|
**Features:**
|
|
- **Event filtering** - Only receives events of specified type
|
|
- **Automatic cleanup** - Unsubscribes on unmount
|
|
- **Type-safe** - Event type is checked at compile time
|
|
|
|
**Examples:**
|
|
|
|
```tsx
|
|
// Listen for file creation
|
|
useEvent('FileCreated', (event) => {
|
|
console.log('New file:', event.FileCreated.path);
|
|
});
|
|
|
|
// Listen for indexing progress
|
|
useEvent('IndexingProgress', (event) => {
|
|
const { location_id, progress } = event.IndexingProgress;
|
|
updateProgress(location_id, progress);
|
|
});
|
|
|
|
// Listen for library sync events
|
|
useEvent('LibrarySynced', (event) => {
|
|
console.log('Library synced:', event.LibrarySynced.library_id);
|
|
});
|
|
|
|
// Listen for job completion
|
|
useEvent('JobCompleted', (event) => {
|
|
const job = event.JobCompleted;
|
|
toast.success(`Job ${job.name} completed!`);
|
|
});
|
|
```
|
|
|
|
### useAllEvents
|
|
|
|
Subscribe to all Spacedrive events (useful for debugging).
|
|
|
|
```tsx
|
|
import { useAllEvents } from '@sd/interface';
|
|
|
|
function EventDebugger() {
|
|
useAllEvents((event) => {
|
|
console.log('Event:', event);
|
|
});
|
|
|
|
return <div>Check console for events</div>;
|
|
}
|
|
```
|
|
|
|
**Warning:** This can be noisy. Use `useEvent` for specific events in production.
|
|
|
|
## Custom Hooks
|
|
|
|
### useLibraries
|
|
|
|
Convenience hook for fetching all libraries.
|
|
|
|
```tsx
|
|
import { useLibraries } from '@sd/interface';
|
|
|
|
function LibraryDropdown() {
|
|
const { data: libraries, isLoading } = useLibraries();
|
|
|
|
if (isLoading) return <Loader />;
|
|
|
|
return (
|
|
<select>
|
|
{libraries?.map(lib => (
|
|
<option key={lib.id} value={lib.id}>
|
|
{lib.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
```
|
|
|
|
**With stats:**
|
|
|
|
```tsx
|
|
const { data: libraries } = useLibraries(true);
|
|
|
|
libraries?.forEach(lib => {
|
|
console.log(`${lib.name}: ${lib.statistics?.total_files} files`);
|
|
});
|
|
```
|
|
|
|
## Client Hook
|
|
|
|
### useSpacedriveClient
|
|
|
|
Access the Spacedrive client instance directly.
|
|
|
|
```tsx
|
|
import { useSpacedriveClient } from '@sd/interface';
|
|
|
|
function LibrarySwitcher() {
|
|
const client = useSpacedriveClient();
|
|
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(null);
|
|
|
|
const switchLibrary = (id: string) => {
|
|
client.setCurrentLibrary(id);
|
|
setCurrentLibraryId(id);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={() => switchLibrary('lib-123')}>
|
|
Switch to Library 123
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Client methods:**
|
|
- `client.setCurrentLibrary(id: string)` - Switch to a library
|
|
- `client.getCurrentLibraryId()` - Get current library ID
|
|
- `client.execute(method, input)` - Execute RPC method directly
|
|
- `client.on(event, handler)` - Subscribe to events
|
|
- `client.off(event, handler)` - Unsubscribe from events
|
|
|
|
## Hook Patterns
|
|
|
|
### Combining Queries
|
|
|
|
```tsx
|
|
function Dashboard() {
|
|
const { data: libraries } = useLibraries();
|
|
const { data: nodeInfo } = useCoreQuery({
|
|
type: 'node.info',
|
|
input: {},
|
|
});
|
|
const { data: jobs } = useLibraryQuery({
|
|
type: 'jobs.list',
|
|
input: {},
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<h1>{nodeInfo?.name}</h1>
|
|
<p>{libraries?.length} libraries</p>
|
|
<p>{jobs?.length} running jobs</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Dependent Queries
|
|
|
|
```tsx
|
|
function FileDetails({ path }: { path: string }) {
|
|
// First get file info
|
|
const { data: file } = useLibraryQuery({
|
|
type: 'files.get',
|
|
input: { path },
|
|
});
|
|
|
|
// Then get metadata (only if file exists)
|
|
const { data: metadata } = useLibraryQuery(
|
|
{
|
|
type: 'files.get_metadata',
|
|
input: { file_id: file?.id },
|
|
},
|
|
{
|
|
enabled: !!file,
|
|
}
|
|
);
|
|
|
|
return <div>{metadata?.size} bytes</div>;
|
|
}
|
|
```
|
|
|
|
### Mutations with Refetch
|
|
|
|
```tsx
|
|
function DeleteButton({ fileId }: { fileId: number }) {
|
|
const queryClient = useQueryClient();
|
|
const deleteFile = useLibraryMutation('files.delete');
|
|
|
|
const handleDelete = () => {
|
|
deleteFile.mutate(
|
|
{ file_ids: [fileId] },
|
|
{
|
|
onSuccess: () => {
|
|
// Refetch file list after deletion
|
|
queryClient.invalidateQueries(['files.directory_listing']);
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
return <button onClick={handleDelete}>Delete</button>;
|
|
}
|
|
```
|
|
|
|
### Optimistic Updates
|
|
|
|
```tsx
|
|
function RenameButton({ fileId, currentName }: { fileId: number, currentName: string }) {
|
|
const queryClient = useQueryClient();
|
|
const rename = useLibraryMutation('files.rename');
|
|
|
|
const handleRename = (newName: string) => {
|
|
rename.mutate(
|
|
{ file_id: fileId, new_name: newName },
|
|
{
|
|
// Optimistically update UI before server confirms
|
|
onMutate: async (variables) => {
|
|
// Cancel outgoing refetches
|
|
await queryClient.cancelQueries(['files.get', fileId]);
|
|
|
|
// Snapshot previous value
|
|
const previous = queryClient.getQueryData(['files.get', fileId]);
|
|
|
|
// Optimistically update
|
|
queryClient.setQueryData(['files.get', fileId], (old: any) => ({
|
|
...old,
|
|
name: variables.new_name,
|
|
}));
|
|
|
|
return { previous };
|
|
},
|
|
// Rollback on error
|
|
onError: (err, variables, context) => {
|
|
queryClient.setQueryData(['files.get', fileId], context?.previous);
|
|
},
|
|
// Refetch after success or error
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries(['files.get', fileId]);
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
return <button onClick={() => handleRename('New Name')}>Rename</button>;
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Use the Right Hook
|
|
|
|
```tsx
|
|
// Good - core query for libraries
|
|
const { data: libraries } = useCoreQuery({
|
|
type: 'libraries.list',
|
|
input: {},
|
|
});
|
|
|
|
// Bad - library query for core operation
|
|
const { data: libraries } = useLibraryQuery({
|
|
type: 'libraries.list', // Type error!
|
|
input: {},
|
|
});
|
|
|
|
// Good - library query for files
|
|
const { data: files } = useLibraryQuery({
|
|
type: 'files.directory_listing',
|
|
input: { path: '/' },
|
|
});
|
|
```
|
|
|
|
### Set Appropriate Stale Times
|
|
|
|
```tsx
|
|
// Static data - long stale time
|
|
const { data: nodeInfo } = useCoreQuery(
|
|
{
|
|
type: 'node.info',
|
|
input: {},
|
|
},
|
|
{
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
}
|
|
);
|
|
|
|
// Dynamic data - short stale time or use useNormalizedCache
|
|
const { data: files } = useNormalizedCache({
|
|
wireMethod: 'query:files.directory_listing',
|
|
input: { path },
|
|
resourceType: 'file',
|
|
});
|
|
```
|
|
|
|
### Handle Loading and Error States
|
|
|
|
```tsx
|
|
// Good - handles all states
|
|
function FileList() {
|
|
const { data, isLoading, error } = useLibraryQuery({
|
|
type: 'files.directory_listing',
|
|
input: { path: '/' },
|
|
});
|
|
|
|
if (isLoading) return <Loader />;
|
|
if (error) return <ErrorMessage error={error} />;
|
|
if (!data) return <EmptyState />;
|
|
|
|
return <div>{data.entries.map(...)}</div>;
|
|
}
|
|
|
|
// Bad - assumes data exists
|
|
function FileList() {
|
|
const { data } = useLibraryQuery({
|
|
type: 'files.directory_listing',
|
|
input: { path: '/' },
|
|
});
|
|
|
|
return <div>{data.entries.map(...)}</div>; // Can crash!
|
|
}
|
|
```
|
|
|
|
### Invalidate Related Queries
|
|
|
|
```tsx
|
|
const createLocation = useLibraryMutation('locations.create');
|
|
|
|
const handleCreate = () => {
|
|
createLocation.mutate(
|
|
{ path: '/photos' },
|
|
{
|
|
onSuccess: () => {
|
|
// Invalidate related queries
|
|
queryClient.invalidateQueries(['locations.list']);
|
|
queryClient.invalidateQueries(['files.directory_listing']);
|
|
},
|
|
}
|
|
);
|
|
};
|
|
```
|
|
|
|
## Summary
|
|
|
|
Spacedrive's React hooks provide:
|
|
|
|
- **Type safety** - All operations are type-checked against Rust definitions
|
|
- **Auto-generation** - Types are generated from Rust, never manually written
|
|
- **TanStack Query** - Full TanStack Query API available
|
|
- **Library scoping** - Automatic library ID management
|
|
- **Event subscriptions** - Real-time updates via WebSocket
|
|
- **Normalized cache** - Instant cross-device sync
|
|
|
|
Use `useCoreQuery` and `useLibraryQuery` for data fetching, `useCoreMutation` and `useLibraryMutation` for mutations, and `useEvent` for event subscriptions.
|