Module: Data Fetching and Caching

Mutations

React JS: Data Fetching and Caching -> Mutations

Introduction to Mutations

While fetching data (queries) is about reading data, mutations are about writing or modifying data. This includes creating, updating, and deleting data on the server. Like queries, handling mutations effectively in React requires careful consideration of state management, optimistic updates, and error handling.

Why Mutations Need Special Handling

Mutations present unique challenges:

  • State Synchronization: After a mutation, your client-side state needs to reflect the changes made on the server. Simply refetching all related data can be inefficient and lead to a poor user experience.
  • Optimistic Updates: To provide a more responsive UI, you might want to immediately update the UI as if the mutation succeeded, even before the server confirms it. This is called an optimistic update. You need to be prepared to revert these changes if the mutation fails.
  • Error Handling: Mutations can fail. You need robust error handling to inform the user and potentially revert optimistic updates.
  • Cache Invalidation: When data is mutated, any cached data that depends on that data becomes stale. You need to invalidate or update the cache accordingly.

Implementing Mutations with fetch (Basic Example)

Let's illustrate a simple mutation using the built-in fetch API. This example will focus on deleting a task.

import React, { useState } from 'react';

function TaskList({ tasks, onDeleteTask }) {
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          {task.title}
          <button onClick={() => onDeleteTask(task.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

function App() {
  const [tasks, setTasks] = useState([
    { id: 1, title: 'Learn React' },
    { id: 2, title: 'Build a Project' },
  ]);

  const handleDeleteTask = async (taskId) => {
    try {
      const response = await fetch(`/api/tasks/${taskId}`, {
        method: 'DELETE',
      });

      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }

      // Optimistic Update: Remove the task from the state immediately
      setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId));

    } catch (error) {
      console.error('Error deleting task:', error);
      // Revert Optimistic Update (if necessary - not shown in this simple example)
      alert('Failed to delete task. Please try again.');
    }
  };

  return (
    <div>
      <h1>Task List</h1>
      <TaskList tasks={tasks} onDeleteTask={handleDeleteTask} />
    </div>
  );
}

export default App;

Explanation:

  1. handleDeleteTask Function: This asynchronous function handles the deletion logic.
  2. fetch Request: It uses fetch to send a DELETE request to the server endpoint /api/tasks/${taskId}.
  3. Error Handling: It checks response.ok to ensure the request was successful. If not, it throws an error.
  4. Optimistic Update: Before waiting for the server's response, it updates the tasks state by filtering out the deleted task. This provides immediate feedback to the user.
  5. Error Reversion (Not Shown): In a more complex application, you'd need to revert the optimistic update if the fetch request fails. This might involve storing the original state before the update and restoring it in the catch block.

Using Libraries for Mutation Management (React Query & SWR)

While the fetch example demonstrates the core concepts, libraries like React Query and SWR significantly simplify mutation handling, especially for complex applications.

1. React Query

React Query provides a dedicated useMutation hook.

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

function App() {
  const queryClient = useQueryClient();

  const deleteTask = async (taskId) => {
    const response = await fetch(`/api/tasks/${taskId}`, {
      method: 'DELETE',
    });

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    return response.json(); // Return data if needed
  };

  const { mutate, isLoading, error } = useMutation({
    mutationFn: deleteTask,
    onSuccess: () => {
      // Invalidate the query to refetch data
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
    },
  });

  const handleDeleteTask = (taskId) => {
    mutate(taskId);
  };

  // ... rest of your component
}

Explanation:

  • useMutation Hook: This hook takes a mutationFn (the function that performs the mutation) and optional callbacks for success, error, and other events.
  • mutate Function: This function triggers the mutation.
  • onSuccess Callback: This callback is executed when the mutation succeeds. Here, we use queryClient.invalidateQueries to invalidate the cache for the tasks query. This forces React Query to refetch the data, ensuring the UI is up-to-date.
  • isLoading and error: These properties provide information about the mutation's status.

2. SWR

SWR also offers a useSWRMutation hook.

import useSWRMutation from 'swr/mutation';

function App() {
  const deleteTask = async (taskId) => {
    const response = await fetch(`/api/tasks/${taskId}`, {
      method: 'DELETE',
    });

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    return response.json();
  };

  const { mutate, isLoading, error } = useSWRMutation(
    '/api/tasks/:id', // Endpoint pattern
    deleteTask,
    {
      onSuccess: () => {
        // SWR automatically refetches the associated key
      },
    }
  );

  const handleDeleteTask = (taskId) => {
    mutate(taskId);
  };

  // ... rest of your component
}

Explanation:

  • useSWRMutation Hook: Similar to React Query, this hook takes a mutation function and optional configuration.
  • Endpoint Pattern: SWR uses an endpoint pattern (/api/tasks/:id) to identify the resource being mutated.
  • Automatic Refetching: SWR automatically refetches the associated key when a mutation succeeds, simplifying cache invalidation.

Optimistic Updates with React Query/SWR

Both React Query and SWR provide mechanisms for optimistic updates. The general approach is:

  1. Update the UI immediately: Modify the local state as if the mutation succeeded.
  2. Call mutate: Trigger the mutation.
  3. Revert on Error: If the mutation fails, revert the UI to its original state.

React Query's onMutate callback is particularly useful for optimistic updates. SWR's onError callback is used for reverting.

Best Practices for Mutations

  • Use a dedicated mutation hook: Keep mutation logic separate from your components.
  • Handle errors gracefully: Provide informative error messages to the user.
  • Consider optimistic updates: Improve the user experience by providing immediate feedback.
  • Invalidate or update the cache: Ensure the UI reflects the latest data.
  • Use a library like React Query or SWR: Simplify mutation management and improve code maintainability.
  • Server-Side Validation: Always validate mutations on the server to prevent data inconsistencies.

This provides a comprehensive overview of mutations in React, covering basic implementation with fetch and advanced techniques using React Query and SWR. Remember to choose the approach that best suits the complexity of your application.