<AnilPal

🖋️>

Client-Only State Management with React Query

hero image for article
🗓️ 07 March 2024⏳ 5 mins read

In the world of frontend development, managing client-side state is as essential as coffee to developers. But when it comes to the tools available, we often find ourselves picking between global state management libraries like Redux, MobX, or Recoil and data-fetching libraries like React Query. Many developers see React Query only as a data-fetching tool, unaware that it can also be a simple yet powerful solution for client-only state management.

In this post, we'll dive into how you can use React Query for managing client-only state. We’ll explore some practical code examples to help you rethink how React Query can streamline state management beyond just fetching data from APIs.


Why Consider React Query for Client-Only State?

While React Query is often chosen for server state management, it also brings specific advantages for managing client-only state. Here’s why using React Query for client-only state can be beneficial:

  1. Unified API for Client and Server State: Using React Query for both server and client-only state unifies your approach, reducing the need to mix state management strategies across different parts of your app. This simplifies both code structure and developer onboarding.

  2. Effortless Cache Management: React Query handles caching seamlessly for client-only state, allowing you to set staleTime and cacheTime to control data longevity. This is especially useful when you need temporary or session-bound data that doesn't persist beyond the session.

  3. Declarative Data Dependencies: With React Query, client-only state updates become declarative. For example, you can easily set up state with useQuery or mutate it with useMutation — no need to create reducers or manage complex contexts.

  4. DevTools Support: The React Query DevTools offer insights not just into server-state caching but also into your client-only state. This means you get a real-time view of client-only states, making debugging and state visualization straightforward.

  5. Optimistic Updates and Predictable Revalidation: React Query’s mutation API allows for optimistic updates with easy rollback functionality, which can be applied to client-only state to provide instant UI feedback and keep the UI responsive.

  6. Flexibility in Data Sources: React Query can handle "mock" data fetches and async operations for client-only state, which is helpful if you plan to replace the local data with server-fetched data in the future without restructuring your code.

Using these capabilities, React Query becomes a powerful tool not only for server data management but also for managing nuanced client-only states efficiently.

So, let's dig into how to set up client-only state with React Query and use its powers for something other than just fetching from APIs.


Setting Up React Query

To use React Query, you'll need to install it. Start by adding it to your project with the following command:

npm install @tanstack/react-query

Then, wrap your app with the QueryClientProvider to make React Query available across your components:

import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryClient = new QueryClient();

const Root = () => (
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

export default Root;

With this setup, we’re ready to use React Query for both client and server state management.


Client-Only State with useQuery

A common scenario for client-only state is toggling a UI feature, like opening or closing a modal. For this example, let’s say we want to manage the visibility of a “user settings” modal.

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

function useModalState() {
  return useQuery({
    queryKey: ['userSettingsModal'],
    queryFn: () => false, // Default state: modal is closed
    staleTime: Infinity, // No need to refresh
  });
}

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

  return () => {
    queryClient.setQueryData(['userSettingsModal'], (prev) => !prev);
  };
}

Here’s what’s happening:

  1. useModalState: We’re using useQuery to fetch the modal's visibility status. Our queryFn returns false, indicating the modal is closed by default.

  2. useToggleModal: This function uses queryClient.setQueryData to toggle the modal state.

Using the Modal State in a Component

Now, let’s wire this up in a component that opens and closes the modal.

import React from 'react';

const SettingsModal = () => {
  const { data: isModalOpen } = useModalState();
  const toggleModal = useToggleModal();

  return (
    <div>
      <button onClick={toggleModal}>
        {isModalOpen ? 'Close' : 'Open'} Settings
      </button>
      {isModalOpen && <div className="modal">User Settings Content</div>}
    </div>
  );
};

This is a simple setup that uses React Query to handle client-only state without needing a reducer, context, or additional state management libraries. It’s low-boilerplate, easy to read, and does the job elegantly.


Persisting State with React Query: Enter useMutation

What if we wanted to store the user’s settings locally before sending them to a server? Here, React Query's useMutation hook comes into play, giving us a simple and predictable way to manage mutations and client-side updates.

Let’s assume we want to let users change their preferred language, store it locally, and eventually send it to the server.

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

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

  return useMutation(
    (newLanguage) => {
      // Local update before sync
      queryClient.setQueryData(['language'], newLanguage);

      // Simulated async operation for server update
      return new Promise((resolve) => {
        setTimeout(() => resolve(newLanguage), 2000);
      });
    },
    {
      onSuccess: (data) => {
        // Update cache with confirmed server data
        queryClient.setQueryData(['language'], data);
      },
    }
  );
}

Using Language Preference in a Component

Now, let’s connect this mutation to a component that lets users select their language preference.

const LanguageSelector = () => {
  const { mutate: setLanguage, isLoading } = useLanguagePreference();
  const currentLanguage = useQueryClient().getQueryData(['language']) || 'en';

  return (
    <div>
      <label>Select Language:</label>
      <select
        value={currentLanguage}
        onChange={(e) => setLanguage(e.target.value)}
        disabled={isLoading}
      >
        <option value="en">English</option>
        <option value="es">Spanish</option>
        <option value="fr">French</option>
      </select>
      {isLoading && <p>Updating language...</p>}
    </div>
  );
};

In this example:

  1. Client-Side Cache Update: useMutation handles the update on the client before syncing with the server, giving users immediate feedback.

  2. Async Simulation: We simulate a server response delay using a timeout. When the server responds, we update the cached language state to reflect the server's confirmation.


Advanced Client-Only State with useInfiniteQuery

Sometimes, client-only state isn’t as simple as opening a modal or toggling a value. Let’s imagine a more dynamic scenario: a paginated list where users scroll through items that aren’t necessarily fetched from a server.

import { useInfiniteQuery } from '@tanstack/react-query';

function usePaginatedItems() {
  return useInfiniteQuery({
    queryKey: ['paginatedItems'],
    queryFn: ({ pageParam = 0 }) => fetchPage(pageParam),
    getNextPageParam: (lastPage, pages) => (lastPage.hasNext ? pages.length : undefined),
  });
}

In this scenario:

  • useInfiniteQuery allows us to manage paginated data, perfect for implementing an infinite scroll.
  • Simulated Pagination: In queryFn, we simulate fetching a new page, while getNextPageParam tells React Query if there are more pages to load.

Using Paginated Items in a Component

Let’s create a component that renders each page as a user scrolls.

const ItemList = () => {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = usePaginatedItems();

  return (
    <div>
      {data?.pages.map((page, pageIndex) => (
        <div key={pageIndex}>
          {page.items.map((item) => (
            <p key={item.id}>{item.name}</p>
          ))}
        </div>
      ))}
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
        {isFetchingNextPage ? 'Loading more...' : 'Load more'}
      </button>
    </div>
  );
};

The above component takes advantage of React Query's built-in pagination and infinite scrolling capabilities, making it easy to manage complex client-only state.


Wrapping Up

Using React Query for client-only state management can simplify your architecture by consolidating state handling in one place, reducing complexity, and eliminating the need for boilerplate-heavy libraries. Whether you’re handling simple toggles, optimistic updates, or paginated data, React Query has tools that let you create robust solutions with minimal code.

To sum up, here are some takeaways for using React Query for client-only state:

  • Avoid Redundant Libraries: If you already use React Query for server-state, it’s often simpler to use it for client-state too.
  • Leverage Caching and Syncing: With built-in caching, reactivity, and mutation management, React Query offers features usually reserved for server-state but can work just as well for client-only state.
  • **Reap the Benefits of Simplicity
© 2024 Anil Pal. All rights reserved.