When building React applications, custom hooks like useFetch are excellent for abstracting away API call boilerplate. However, a common architectural dilemma arises when you need to modify the fetched data locally—such as deleting an item from a list or updating an inline form. Should you copy the fetched data into a local component state, or is there a better way?

The Golden Rule: Never Mutate State Directly

Before exploring the solutions, let's address the code snippet from the question:

const deleteActor = async (actor) => {
  const index = data.indexOf(actor);
  await axiosPrivateDelete(`api/actors/${actor.id}`);
  
  // ❌ WARNING: Direct Mutation!
  data.splice(index, 1);
};

In React, you must never mutate state directly. The data object returned from your useFetch hook is tied to the hook's internal state. Splicing it directly bypasses React's virtual DOM reconciliation. Because the array reference remains the same, React will not detect any changes, and your UI will not re-render to reflect the deletion.


How to Handle Locally Mutating Fetched Data

There are three primary ways to solve this problem, ranging from simple refactoring to adopting modern industry standards.

Solution 1: Expose the State Setter from Your Custom Hook (Recommended)

The cleanest and most straightforward fix is to have your useFetch hook return its state setter function (e.g., setData). This allows your consuming component to update the state safely without duplicating it.

First, update your custom hook to return setData:

// useFetch.js
function useFetch(url) {
  const [data, setData] = useState(null);
  // ... loading and error states

  return { data, setData, loading, error };
}

Next, in your ActorList component, use the React-compliant immutable state update pattern (like .filter()) to remove the deleted item:

const ActorList = () => {
  // Destructure setData from your hook
  const { data, setData, loading } = useFetch("/api/actors");

  const deleteActor = async (actor) => {
    try {
      await axiosPrivateDelete(`api/actors/${actor.id}`);
      
      // Safe, immutable state update
      setData((prevData) => prevData.filter((item) => item.id !== actor.id));
      
      setActorDelete({});
      setDeleteModalOpen(false);
    } catch (error) {
      console.error("Failed to delete actor", error);
    }
  };

  // ... render logic
};

Why this works: .filter() returns a brand-new array. When you call setData with a new array reference, React immediately triggers a re-render, updating your UI seamlessly.


Solution 2: Copying Fetched Data to Local State (Use with Caution)

Another approach is to initialize a local state inside your component and sync it with the fetched data using a useEffect hook.

const { data: fetchedActors, loading } = useFetch("/api/actors");
const [actors, setActors] = useState([]);

// Sync fetched data to local state
useEffect(() => {
  if (fetchedActors) {
    setActors(fetchedActors);
  }
}, [fetchedActors]);

Why this is generally discouraged: This pattern introduces "duplicate sources of truth" and can lead to synchronization bugs. It also causes an extra render cycle (one when the hook finishes fetching, and another when the useEffect updates the local state).


Solution 3: Move to a Server-State Library (The Professional Way)

If your application performs frequent CRUD operations (Create, Read, Update, Delete), writing custom fetch hooks and manually syncing state becomes highly repetitive and error-prone.

In modern React development, the industry standard is to use libraries like TanStack Query (React Query) or SWR. These libraries treat server data differently than local UI state, providing built-in caching, automatic refetching, and easy mutation handling:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// Fetching data
const { data: actors, isLoading } = useQuery({ 
  queryKey: ['actors'], 
  queryFn: fetchActors 
});

const queryClient = useQueryClient();

// Mutating data (Deleting)
const deleteMutation = useMutation({
  mutationFn: (id) => axiosPrivateDelete(`api/actors/${id}`),
  onSuccess: () => {
    // Automatically invalidates cache and triggers a background refetch
    queryClient.invalidateQueries({ queryKey: ['actors'] });
  },
});

Using a library like TanStack Query completely eliminates the need to manually manage state arrays, handle loading/error states, or worry about state synchronization.

Summary

  • Never mutate state directly using methods like .splice(). Always use immutable update patterns like .filter() or .map().
  • For simple custom hooks, expose the state setter (setData) from the hook to mutate data safely.
  • Avoid duplicating fetched data into a local useState unless absolutely necessary.
  • For production-grade applications, migrate to TanStack Query or SWR to handle server-state management effortlessly.