Understanding React Query: Invalidation and Key Functions

Detailed guide on the usage of useQuery(), setQueryData(), and invalidateQueries() in React Query with practical examples and scenarios.


Understanding React Query: Invalidation and Key Functions

React Query is a powerful library for managing server state in React applications. It simplifies data fetching, caching, synchronization, and more. This document focuses on understanding invalidation in React Query and the usage of key functions: useQuery(), setQueryData(), and invalidateQueries().

What is Invalidation?

Invalidation in React Query refers to the process of marking cached data as stale. This triggers a refetch of the data the next time it is accessed. Invalidation is crucial for keeping the UI in sync with the server data, especially in applications where data changes frequently.

Key Functions

useQuery()

The useQuery hook is the primary hook for fetching and caching data. It accepts a query key and a function that returns a promise, which resolves with the data. Here is an example of how you can use useQuery:

import { useQuery } from "@tanstack/react-query";
 
const fetchPosts = async () => {
  const response = await fetch("/api/posts");
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
  return response.json();
};
 
const Posts = () => {
  const { data, error, isLoading } = useQuery(["posts"], fetchPosts);
 
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
 
  return (
    <div>
      {data.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
};

In this example, useQuery is used to fetch a list of posts from an API. The queryKeyis an array that identifies the query, and the queryFn is a function that fetches the data. The useQuery hook returns an object with three properties: data, error, and isLoading. data is the fetched data, error is the error if there is one, and isLoading is a boolean that indicates whether the data is being fetched.

Business Scenario:

Use useQuery to fetch data that is read-only or rarely changes, such as a list of posts or user profiles.

setQueryData()

The setQueryData function allows you to directly manipulate the cached data without triggering a refetch. This is useful for optimistic updates or when you know the data has changed. Example:

import { useQueryClient } from "@tanstack/react-query";
 
const updatePostTitle = async (postId, newTitle) => {
  const response = await fetch(`/api/posts/${postId}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title: newTitle }),
  });
 
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
 
  return response.json();
};
 
const EditPostTitle = ({ postId, currentTitle }) => {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState(currentTitle);
 
  const handleSave = async () => {
    await updatePostTitle(postId, title);
    queryClient.setQueryData(["posts"], (oldData) => {
      return oldData.map((post) =>
        post.id === postId ? { ...post, title } : post
      );
    });
  };
 
  return (
    <div>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button onClick={handleSave}>Save</button>
    </div>
  );
};

Business Scenario:

Use setQueryData for optimistic UI updates, such as updating the UI immediately after a form submission, while the actual request is still being processed.

invalidateQueries()

The invalidateQueries function marks queries as stale and triggers a refetch the next time they are accessed. This is essential for ensuring that the data displayed in the UI is up-to-date. Example:

import { useMutation, useQueryClient } from "@tanstack/react-query";
 
const deletePost = async (postId) => {
  const response = await fetch(`/api/posts/${postId}`, {
    method: "DELETE",
  });
 
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
 
  return response.json();
};
 
const DeletePostButton = ({ postId }) => {
  const queryClient = useQueryClient();
 
  const mutation = useMutation(() => deletePost(postId), {
    onSuccess: () => {
      queryClient.invalidateQueries(["posts"]);
    },
  });
 
  return (
    <button onClick={() => mutation.mutate()}>
      {mutation.isLoading ? "Deleting..." : "Delete Post"}
    </button>
  );
};

Business Scenario:

Use invalidateQueries when the data in the cache may no longer be accurate after an operation, such as after deleting or adding an item. This ensures that the next fetch retrieves the latest data from the server.

Practical Examples

Scenario: Bookmarking a Post

When a user bookmarks a post, we need to update the cached data to reflect this change and ensure the bookmarks page displays the correct information.

Backend:

export const bookmarkPost = async (req, res) => {
  try {
    const userId = req.user._id;
    const { id: postId } = req.params;
 
    const post = await Post.findById(postId);
 
    if (!post) {
      return res.status(404).json({ error: "Post not found" });
    }
 
    const userBookmarkedPost = post.bookmarks.includes(userId);
 
    if (userBookmarkedPost) {
      await Post.updateOne({ _id: postId }, { $pull: { bookmarks: userId } });
      await User.updateOne(
        { _id: userId },
        { $pull: { bookmarkedPosts: postId } }
      );
      const updatedBookmarks = post.bookmarks.filter(
        (id) => id.toString() !== userId.toString()
      );
      res.status(200).json(updatedBookmarks);
    } else {
      post.bookmarks.push(userId);
      await User.updateOne(
        { _id: userId },
        { $push: { bookmarkedPosts: postId } }
      );
      await post.save();
 
      await new Notification({
        from: userId,
        to: post.user,
        type: "bookmark",
      }).save();
 
      const updatedBookmarks = post.bookmarks;
      res.status(200).json(updatedBookmarks);
    }
  } catch (error) {
    console.error("Error in bookmarkPost controller:", error);
    res.status(500).json({ error: "Internal server error" });
  }
};

Frontend:

import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
 
const Post = ({ post }) => {
  const queryClient = useQueryClient();
  const location = useLocation();
 
  const { mutate: bookmarkPost, isPending: isBookmarking } = useMutation({
    mutationFn: async () => {
      const res = await fetch(`/api/posts/bookmark/${post._id}`, {
        method: "POST",
      });
      if (!res.ok) throw new Error(await res.text());
      return await res.json();
    },
    onSuccess: (updatedBookmarks) => {
      if (/^\/bookmarks\/[^\/]+$/.test(location.pathname)) {
        queryClient.invalidateQueries(["posts"]);
      } else {
        queryClient.setQueryData(["posts"], (oldData) =>
          oldData.map((p) =>
            p._id === post._id ? { ...p, bookmarks: updatedBookmarks } : p
          )
        );
      }
      toast.success("Bookmark updated successfully!");
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });
 
  const handleBookmark = () => {
    if (!isBookmarking) bookmarkPost();
  };
 
  return (
    <button onClick={handleBookmark}>
      {isBookmarking ? "Bookmarking..." : "Bookmark"}
    </button>
  );
};

References

For further reading on React Query routing, refer to the official React Query documentation.