Skip to content

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.