State Management¶
Sapari uses React Query for server state management. This page covers the patterns and conventions we use.
Why React Query¶
React Query handles the hard parts of server state: - Caching and deduplication - Background refetching - Optimistic updates - Request deduplication - Automatic retries
We don't use Redux or other client state libraries - React Query plus React's built-in useState/useContext covers our needs.
Query Keys¶
Each feature has query keys defined in its api.ts file:
// features/projects/api.ts
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (filters?: Record<string, unknown>) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, 'detail'] as const,
detail: (uuid: string) => [...projectKeys.details(), uuid] as const,
};
This hierarchy enables granular invalidation:
// Invalidate all project queries
queryClient.invalidateQueries({ queryKey: projectKeys.all });
// Invalidate just the list (keeps individual details cached)
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
// Invalidate one specific project
queryClient.invalidateQueries({ queryKey: projectKeys.detail('uuid-here') });
Analysis runs follow the same pattern:
export const analysisRunKeys = {
all: ['analysis-runs'] as const,
byProject: (projectUuid: string) => [...analysisRunKeys.all, 'project', projectUuid] as const,
};
When switching runs via useActivateRun(), all dependent caches are invalidated: editKeys, captionKeys, draftKeys, projectKeys, and analysisRunKeys.
Query Hooks¶
Fetching data uses useQuery:
// features/clips/hooks.ts
export function useClips(projectUuid: string) {
return useQuery({
queryKey: clipKeys.byProject(projectUuid),
queryFn: async () => {
const response = await clipApi.list(projectUuid);
return response.data.map(toFrontendClip);
},
enabled: !!projectUuid, // Don't fetch until we have a UUID
staleTime: 5000, // Consider fresh for 5 seconds
});
}
Components use the hook and get loading/error states for free:
function ClipList({ projectUuid }: { projectUuid: string }) {
const { data: clips, isLoading, error } = useClips(projectUuid);
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <ul>{clips.map(clip => <ClipItem key={clip.id} clip={clip} />)}</ul>;
}
Mutation Hooks¶
Mutations use useMutation with optimistic updates:
// features/edits/hooks.ts
export function useToggleEdit() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
editApi.update(uuid, { active }),
onMutate: async ({ uuid, active }) => {
const projectUuid = getCurrentProjectUuid();
await queryClient.cancelQueries({ queryKey: editKeys.byProject(projectUuid) });
const previous = queryClient.getQueryData<Edit[]>(editKeys.byProject(projectUuid));
queryClient.setQueryData<Edit[]>(editKeys.byProject(projectUuid), (old) =>
old?.map((edit) => (edit.id === uuid ? { ...edit, active } : edit))
);
return { previous, projectUuid };
},
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(editKeys.byProject(context.projectUuid), context.previous);
}
},
});
}
Polling for Background Processes¶
Not all updates use SSE. For simpler background processes like asset downloads, we use React Query's refetchInterval:
// features/assets/hooks.ts
export function useAssets(groupId?: string, page: number = 1) {
return useQuery({
queryKey: assetKeys.list(groupId),
queryFn: () => assetApi.list(page, 50, groupId),
staleTime: 30 * 1000,
// Poll every 3s while assets are downloading
refetchInterval: (query) => {
const assets = query.state.data?.data;
if (!assets) return false;
const hasPending = assets.some((a) => a.status === 'pending');
return hasPending ? 3000 : false;
},
});
}
| Pattern | Use Case | Examples |
|---|---|---|
| SSE | Real-time critical, user actively watching | Clip processing, Analysis pipeline |
| Polling | Background process, simpler implementation | Exports, Asset downloads |
See SSE Integration for more details on when to use each pattern.
Cache Invalidation via SSE¶
The useProjectEvents hook automatically invalidates queries when relevant events arrive:
// features/projects/hooks.ts
export function useProjectEvents(projectUuid: string) {
const queryClient = useQueryClient();
useEffect(() => {
const unsubscribe = subscribeToProject(projectUuid, {
onClipReady: () => {
queryClient.invalidateQueries({ queryKey: clipKeys.byProject(projectUuid) });
},
onAnalysisComplete: () => {
queryClient.invalidateQueries({ queryKey: editKeys.byProject(projectUuid) });
},
onExportComplete: () => {
queryClient.invalidateQueries({ queryKey: exportKeys.byProject(projectUuid) });
},
});
return unsubscribe;
}, [projectUuid, queryClient]);
}
Components just need to call useProjectEvents(projectUuid) and their data hooks will automatically refetch when relevant events occur.
Stale Time vs Cache Time¶
Two important React Query concepts:
- staleTime: How long data is considered fresh. Fresh data won't trigger a refetch.
- cacheTime: How long inactive data stays in cache before garbage collection.
Our defaults:
// Short stale time - data changes frequently
const { data } = useQuery({
queryKey: editKeys.byProject(projectUuid),
queryFn: fetchEdits,
staleTime: 5000, // 5 seconds
});
// Longer for static data
const { data } = useQuery({
queryKey: ['user', 'profile'],
queryFn: fetchProfile,
staleTime: 60000, // 1 minute
});
Prefetching¶
For predictable navigation, prefetch data before the user clicks:
function ProjectCard({ project }: { project: Project }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: clipKeys.byProject(project.id),
queryFn: () => clipApi.list(project.id),
});
};
return (
<Link to={`/projects/${project.id}`} onMouseEnter={handleMouseEnter}>
{project.name}
</Link>
);
}
Dependent Queries¶
When one query depends on another:
function ProjectEditor({ projectUuid }: { projectUuid: string }) {
// First, fetch the project
const { data: project } = useProject(projectUuid);
// Then fetch clips only after we have the project
const { data: clips } = useClips(projectUuid);
// Then fetch edits only after we have clips
const { data: edits } = useEdits(projectUuid, {
enabled: !!clips?.length, // Don't fetch if no clips
});
}
Key Files¶
| Component | Location |
|---|---|
| Project hooks | features/projects/hooks.ts |
| Clip hooks | features/clips/hooks.ts |
| Edit hooks | features/edits/hooks.ts |
| Export hooks | features/exports/hooks.ts |
| Draft hooks | features/drafts/hooks.ts |
| Asset hooks | features/assets/hooks.ts |
| Query client setup | shared/api/queryClient.ts |
← Architecture SSE Integration →
See also: Mobile Interface for SwipeReview and FocusMode components.