Using React 19 useOptimistic Hook in OpenCut: A Complete Implementation Guide

OpenCut does not currently ship with a built-in optimistic update hook, but you can implement one using TanStack Query's useMutation lifecycle methods to instantly update the UI while syncing with the backend API.

The OpenCut video editor's frontend is a modern React application built with TanStack Router and a component library located in apps/web/src/components/ui. While the codebase handles UI rendering and routing comprehensively, optimistic data-fetching patterns must be added manually to the API module at apps/api/src/index.ts. This guide shows you how to implement optimistic updates in OpenCut using the existing TanStack Query integration rather than React 19's native useOptimistic hook, which is better suited for local state management while TanStack Query handles server-state synchronization.

Understanding OpenCut's Frontend Architecture

OpenCut's web application relies on TanStack Router for routing and server-state management. The router configuration in apps/web/src/router.tsx initializes a queryClient that powers data fetching across the application.

The router setup creates an internal query client that you can access for optimistic updates:

// apps/web/src/router.tsx
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';

export function getRouter() {
  const router = createTanStackRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
  });

  return router;
}

// Export the query client that TanStack Router creates internally
declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof getRouter>;
  }
}

The router._router.state.queryClient field provides access to the cache instance, though you will typically use the useQueryClient hook from @tanstack/react-query within components.

Implementing the Optimistic Update Hook

Because OpenCut bundles @tanstack/react-router-ssr-query for server-side rendering support, you can leverage the full TanStack Query ecosystem to build optimistic UI patterns.

Installing Dependencies

First, add TanStack Query to the web application if not already present. Check apps/web/package.json for the dependency, or install it:

npm add @tanstack/react-query

# or

bun add @tanstack/react-query

This brings the useMutation and useQueryClient hooks into the bundle, which are essential for the optimistic update pattern.

Creating the useOptimisticUpdate Hook

Create a new file at apps/web/src/hooks/useOptimisticUpdate.tsx that encapsulates the optimistic lifecycle. This hook wraps useMutation and handles the cache manipulation automatically:

// apps/web/src/hooks/useOptimisticUpdate.tsx
import { useMutation, QueryClient, useQueryClient } from '@tanstack/react-query';
import type { MutationFunction, UseMutationOptions } from '@tanstack/react-query';

/**
 * Generic optimistic-update hook.
 *
 * @param mutationFn   Function that performs the real server request.
 * @param queryKey     Cache key of the data that will be updated optimistically.
 * @param getOptimisticUpdate  (optional) Function that returns the optimistic
 *                             value based on the variables passed to mutate().
 */
export function useOptimisticUpdate<TData, TVariables, TContext = unknown>(
  mutationFn: MutationFunction<TData, TVariables>,
  queryKey: readonly unknown[],
  getOptimisticUpdate?: (variables: TVariables) => TData,
  options?: UseMutationOptions<TData, unknown, TVariables, TContext>
) {
  const queryClient = useQueryClient();

  return useMutation<TData, unknown, TVariables, TContext>(mutationFn, {
    // Called *before* the network request – we apply the optimistic change.
    async onMutate(variables) {
      // Cancel any outgoing refetches so they don’t overwrite our optimistic data.
      await queryClient.cancelQueries({ queryKey });

      // Snapshot the previous value.
      const previousData = queryClient.getQueryData<TData>(queryKey);

      // Apply optimistic update if a mapper is supplied.
      if (getOptimisticUpdate) {
        const optimisticData = getOptimisticUpdate(variables);
        queryClient.setQueryData<TData>(queryKey, optimisticData);
      }

      // Return a context object with the previous data to enable rollback.
      return { previousData } as unknown as TContext;
    },

    // If the request fails, roll back to the previous cache.
    onError(_err, _variables, context) {
      if (context && (context as any).previousData !== undefined) {
        queryClient.setQueryData<TData>(queryKey, (context as any).previousData);
      }
    },

    // After success or failure, refetch to sync with the server.
    onSettled() {
      queryClient.invalidateQueries({ queryKey });
    },

    // Spread any extra options supplied by the caller (e.g., retry, staleTime).
    ...options,
  });
}

This implementation follows the optimistic update pattern by immediately updating the cache in onMutate, rolling back in onError, and reconciling with the server in onSettled.

Wiring the Hook to UI Components

To use this hook in OpenCut's UI layer, import it into a component such as the Delete Project button at apps/web/src/components/ui/button.tsx:

// apps/web/src/components/ui/button.tsx
import { FC } from 'react';
import { useOptimisticUpdate } from '#/hooks/useOptimisticUpdate';
import { fetchDeleteProject } from '#/api/project'; // Implement this fetch wrapper

type DeleteButtonProps = {
  projectId: string;
};

export const DeleteProjectButton: FC<DeleteButtonProps> = ({ projectId }) => {
  const queryClient = useQueryClient();
  
  // Optimistically remove the project from the list cached under ["projects"]
  const { mutate: deleteProject, isLoading } = useOptimisticUpdate(
    fetchDeleteProject,                // Real API call to apps/api
    ['projects'],                      // Cache key used elsewhere with useQuery
    (vars) => {
      // Remove the item from the cached array immediately.
      const cached = queryClient.getQueryData<Project[]>(['projects']) ?? [];
      return cached.filter((p) => p.id !== vars);
    }
  );

  return (
    <button
      disabled={isLoading}
      onClick={() => deleteProject(projectId)}
      className="bg-red-600 text-white px-4 py-2 rounded"
    >
      {isLoading ? 'Deleting…' : 'Delete Project'}
    </button>
  );
};

The fetchDeleteProject function should be a thin wrapper around fetch or axios that calls the OpenCut API endpoint (e.g., DELETE /api/projects/:id) defined in apps/api/src/index.ts.

Displaying Data That Benefits from Optimistic Updates

Components that display the data should use useQuery with the same cache key. For example, in a route component:

// apps/web/src/routes/__root.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchProjects } from '#/api/project';

export default function ProjectsPage() {
  const { data: projects, isLoading } = useQuery(['projects'], fetchProjects);

  if (isLoading) return <div>Loading…</div>;

  return (
    <ul>
      {projects?.map((p) => (
        <li key={p.id} className="flex justify-between items-center">
          {p.name}
          <DeleteProjectButton projectId={p.id} />
        </li>
      ))}
    </ul>
  );
}

When the user clicks Delete, the UI instantly removes the project from the list. If the server reports an error, the hook rolls the cache back to its previous state automatically.

How Optimistic Updates Work in OpenCut's Architecture

The optimistic update pattern integrates with OpenCut's existing architecture through three layers:

  1. Routing & Data Layer – TanStack Router (apps/web/src/router.tsx) creates the queryClient that serves as the single source of truth for server state.
  2. UI Layer – Components in apps/web/src/components/ui remain declarative and side-effect-free, delegating mutation logic to the custom hook.
  3. API Layer – Network requests live in the API package (apps/api/src/index.ts), maintaining clean separation between presentation and data access.

This approach reuses the existing queryClient from TanStack Router, keeping the implementation lightweight and consistent with OpenCut's SSR-query integration.

Testing the Implementation

OpenCut uses Vitest for unit testing, configured in apps/web/package.json. To verify your optimistic update hook:

npm test

Write tests that assert:

  • The cache changes immediately after calling mutate
  • A failed request restores the previous state via the rollback mechanism
  • The onSettled callback invalidates and refetches the query

Summary

  • OpenCut's frontend uses TanStack Router with an integrated queryClient for server-state management.
  • The repository does not include a built-in optimistic hook, requiring a custom implementation using @tanstack/react-query.
  • Create apps/web/src/hooks/useOptimisticUpdate.tsx to encapsulate the onMutate, onError, and onSettled lifecycle.
  • Wire the hook into UI components like apps/web/src/components/ui/button.tsx for instant UI feedback.
  • The implementation maintains consistency with OpenCut's monorepo structure: UI components → Router → API layer.

Frequently Asked Questions

What is the difference between React 19's useOptimistic and TanStack Query's optimistic updates?

React 19's useOptimistic hook is designed for local state updates, such as form inputs or UI toggles that don't require server synchronization. TanStack Query's optimistic mutation pattern is better suited for OpenCut because it handles server-state, network errors, and automatic cache reconciliation. The useOptimisticUpdate hook implemented above provides the same instant UI feedback while maintaining data integrity with the backend API at apps/api/src/index.ts.

Where should I place the API fetch functions in OpenCut?

Place API fetch functions like fetchDeleteProject in the API module at apps/api/src/index.ts or in a dedicated apps/web/src/api/ directory. This maintains the monorepo's separation of concerns: UI components import from the API layer, while the optimistic hook in apps/web/src/hooks/ manages the cache interaction.

How do I access the queryClient outside of React components?

The TanStack Router instance exposes router._router.state.queryClient, though this is a private field. According to the source in apps/web/src/router.tsx, you should use the useQueryClient hook from @tanstack/react-query within components, or pass the client explicitly through context if needed outside the React tree.

Can I use this pattern with OpenCut's SSR setup?

Yes. OpenCut bundles @tanstack/react-router-ssr-query for server-side rendering. The useOptimisticUpdate hook works seamlessly with SSR because it uses the same queryClient instance created by the router in apps/web/src/router.tsx. The optimistic updates only execute on the client after hydration, ensuring no mismatch between server-rendered HTML and the initial client state.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →