RTK Query Basics

Introduction to RTK Query

RTK Query is a powerful data fetching and caching library that is included with Redux Toolkit. It eliminates the need to hand-write data fetching and caching logic yourself.

📡 The Smart TV Analogy

Think of RTK Query as a smart TV compared to traditional cable:

  • Traditional Redux: Like cable TV - you manually tune channels, record shows, manage storage
  • RTK Query: Like a smart TV - automatically fetches content, caches episodes, updates in background
  • Endpoints: Different streaming services (Netflix, Hulu, etc.)
  • Cache: Downloaded episodes for offline viewing
  • Auto-refetching: Auto-updates when new episodes are available

Just as a smart TV manages content automatically, RTK Query manages your API data, caching, and updates automatically.

Why RTK Query?

graph TD A[Traditional Data Fetching] --> B[Manual Thunks] A --> C[Manual Loading States] A --> D[Manual Error Handling] A --> E[Manual Caching] A --> F[Manual Refetching] G[RTK Query] --> H[Auto-generated Hooks] G --> I[Built-in Loading States] G --> J[Built-in Error Handling] G --> K[Automatic Caching] G --> L[Automatic Refetching] style A fill:#f96 style G fill:#9f9

Problems RTK Query Solves

Before (Manual Data Fetching)


// actions/userActions.js
export const fetchUser = (id) => async (dispatch) => {
  dispatch({ type: 'FETCH_USER_REQUEST' });
  try {
    const response = await api.get(`/users/${id}`);
    dispatch({ 
      type: 'FETCH_USER_SUCCESS', 
      payload: response.data 
    });
  } catch (error) {
    dispatch({ 
      type: 'FETCH_USER_FAILURE', 
      payload: error.message 
    });
  }
};

// reducers/userReducer.js
const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'FETCH_USER_REQUEST':
      return { ...state, loading: true, error: null };
    case 'FETCH_USER_SUCCESS':
      return { 
        ...state, 
        loading: false, 
        user: action.payload 
      };
    case 'FETCH_USER_FAILURE':
      return { 
        ...state, 
        loading: false, 
        error: action.payload 
      };
    default:
      return state;
  }
};

// components/UserProfile.js
const UserProfile = ({ userId }) => {
  const dispatch = useDispatch();
  const { user, loading, error } = useSelector(
    state => state.user
  );
  
  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [dispatch, userId]);
  
  if (loading) return 
Loading...
; if (error) return
Error: {error}
; if (!user) return null; return
{user.name}
; };

After (RTK Query)


// services/api.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query({
      query: (id) => `users/${id}`,
    }),
  }),
});

export const { useGetUserQuery } = api;

// components/UserProfile.js
const UserProfile = ({ userId }) => {
  const { 
    data: user, 
    isLoading, 
    error 
  } = useGetUserQuery(userId);
  
  if (isLoading) return 
Loading...
; if (error) return
Error: {error.message}
; if (!user) return null; return
{user.name}
; };

Core Concepts of RTK Query

graph TD A[createApi] --> B[API Slice] B --> C[baseQuery] B --> D[endpoints] D --> E[Query Endpoints] D --> F[Mutation Endpoints] E --> G[Auto-generated Hooks] F --> H[Auto-generated Hooks] G --> I[useQuery Hooks] H --> J[useMutation Hooks] style A fill:#f96 style B fill:#9cf style G fill:#9f9 style H fill:#9f9

1. Creating an API Service


import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// Define our single API slice
export const apiSlice = createApi({
  // The cache reducer expects to be added at `state.api`
  reducerPath: 'api',
  
  // All of our requests will have URLs starting with '/api'
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  
  // The "endpoints" represent operations and requests for this server
  endpoints: (builder) => ({
    // Query endpoints - for fetching data
    getPosts: builder.query({
      query: () => '/posts',
    }),
    getPost: builder.query({
      query: (postId) => `/posts/${postId}`,
    }),
    
    // Mutation endpoints - for creating/updating/deleting data
    addNewPost: builder.mutation({
      query: (initialPost) => ({
        url: '/posts',
        method: 'POST',
        body: initialPost,
      }),
    }),
    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch,
      }),
    }),
  }),
});

// Export hooks for usage in functional components
export const {
  useGetPostsQuery,
  useGetPostQuery,
  useAddNewPostMutation,
  useUpdatePostMutation,
} = apiSlice;
            

2. Configuring the Store


import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from './features/api/apiSlice';

export const store = configureStore({
  reducer: {
    // Add the generated reducer as a specific top-level slice
    [apiSlice.reducerPath]: apiSlice.reducer,
    // ... other reducers
  },
  // Adding the api middleware enables caching, invalidation, polling,
  // and other useful features of `rtk-query`.
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware),
});
            

3. Using Query Endpoints


// Basic query usage
const PostsList = () => {
  const {
    data: posts,
    isLoading,
    isSuccess,
    isError,
    error,
  } = useGetPostsQuery();

  if (isLoading) {
    return 
Loading...
; } if (isError) { return
Error: {error.message}
; } return (
    {posts.map((post) => (
  • {post.title}
  • ))}
); }; // Query with parameters const SinglePost = ({ postId }) => { const { data: post, isLoading } = useGetPostQuery(postId); if (isLoading) return
Loading...
; if (!post) return
Post not found!
; return (

{post.title}

{post.content}

); }; // Conditional fetching const UserPosts = ({ userId }) => { const { data: posts } = useGetPostsQuery(undefined, { skip: !userId, // Skip the query if userId is not available }); return posts ? <PostsList posts={posts} /> : null; };

4. Using Mutation Endpoints


const AddPostForm = () => {
  const [addNewPost, { isLoading }] = useAddNewPostMutation();
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (title && content) {
      try {
        await addNewPost({ title, content }).unwrap();
        setTitle('');
        setContent('');
      } catch (error) {
        console.error('Failed to save the post: ', error);
      }
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Post Title"
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Post Content"
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Saving...' : 'Save Post'}
      </button>
    </form>
  );
};

// Mutation with optimistic updates
const EditablePostName = ({ post }) => {
  const [updatePost, { isLoading }] = useUpdatePostMutation();
  const [title, setTitle] = useState(post.title);

  const handleChange = (e) => setTitle(e.target.value);

  const handleBlur = () => {
    if (title !== post.title) {
      updatePost({ id: post.id, title });
    }
  };

  return (
    
  );
};
            

Advanced RTK Query Features

1. Cache Management and Tags


const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'User'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      // Provides a list of `Post` tags
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post', id })),
              { type: 'Post', id: 'LIST' },
            ]
          : [{ type: 'Post', id: 'LIST' }],
    }),
    getPost: builder.query({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    addPost: builder.mutation({
      query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
      }),
      // Invalidates all Post-type queries providing the `LIST` id
      invalidatesTags: [{ type: 'Post', id: 'LIST' }],
    }),
    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      // Invalidates all queries that provide this Post's id
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
    deletePost: builder.mutation({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
});
            

2. Transforming Responses


const apiSlice = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      transformResponse: (response) => {
        // Sort posts by date
        return response.sort((a, b) => 
          new Date(b.date).getTime() - new Date(a.date).getTime()
        );
      },
    }),
    getUsers: builder.query({
      query: () => '/users',
      transformResponse: (response) => {
        // Transform array to object keyed by ID
        return response.reduce((acc, user) => {
          acc[user.id] = user;
          return acc;
        }, {});
      },
    }),
    searchPosts: builder.query({
      query: (searchTerm) => `/posts/search?q=${searchTerm}`,
      transformResponse: (response, meta, arg) => {
        // Add search term to each result
        return response.map(post => ({
          ...post,
          searchTerm: arg,
          highlighted: post.title.includes(arg),
        }));
      },
    }),
  }),
});
            

3. Polling and Refetching


// Polling for updates
const LatestPosts = () => {
  const { data: posts } = useGetPostsQuery(undefined, {
    pollingInterval: 3000, // Poll every 3 seconds
  });

  return ;
};

// Manual refetching
const RefreshablePosts = () => {
  const { data: posts, refetch, isFetching } = useGetPostsQuery();

  return (
    
); }; // Automatic refetch on window focus const AutoRefreshPosts = () => { const { data: posts } = useGetPostsQuery(undefined, { refetchOnFocus: true, refetchOnReconnect: true, }); return ; };

4. Customizing Queries


const apiSlice = createApi({
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      // Get token from Redux state
      const token = getState().auth.token;
      if (token) {
        headers.set('Authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  endpoints: (builder) => ({
    // Custom query function
    searchUsers: builder.query({
      queryFn: async (searchTerm, _queryApi, _extraOptions, fetchWithBQ) => {
        // Custom logic before the request
        if (searchTerm.length < 3) {
          return { data: [] };
        }

        // Use the base query function
        const result = await fetchWithBQ(`/users/search?q=${searchTerm}`);
        
        // Custom logic after the request
        if (result.data) {
          return {
            data: result.data.filter(user => user.active),
          };
        }
        
        return { error: result.error };
      },
    }),
    
    // Query with custom cache time
    getExpensiveData: builder.query({
      query: () => '/expensive-data',
      keepUnusedDataFor: 3600, // Keep in cache for 1 hour
    }),
  }),
});
            

Real-World Example: Blog API


// features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      const token = getState().auth.token;
      if (token) {
        headers.set('Authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  tagTypes: ['Post', 'User', 'Comment'],
  endpoints: (builder) => ({
    // Posts
    getPosts: builder.query({
      query: (params = {}) => ({
        url: '/posts',
        params, // page, limit, category, etc.
      }),
      providesTags: (result = []) => [
        'Post',
        ...result.map(({ id }) => ({ type: 'Post', id })),
      ],
      transformResponse: (response, meta) => {
        // Extract pagination info from headers
        return {
          posts: response,
          totalCount: Number(meta.response.headers.get('X-Total-Count')),
        };
      },
    }),
    
    getPost: builder.query({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    
    createPost: builder.mutation({
      query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post,
      }),
      invalidatesTags: ['Post'],
    }),
    
    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
      async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
        // Optimistic update
        const patchResult = dispatch(
          apiSlice.util.updateQueryData('getPost', id, (draft) => {
            Object.assign(draft, patch);
          })
        );
        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
        }
      },
    }),
    
    // Comments
    getComments: builder.query({
      query: (postId) => `/posts/${postId}/comments`,
      providesTags: (result, error, postId) => [
        { type: 'Comment', id: `POST_${postId}` },
      ],
    }),
    
    addComment: builder.mutation({
      query: ({ postId, content }) => ({
        url: `/posts/${postId}/comments`,
        method: 'POST',
        body: { content },
      }),
      invalidatesTags: (result, error, { postId }) => [
        { type: 'Comment', id: `POST_${postId}` },
      ],
    }),
    
    // Users
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['User'],
    }),
    
    getUser: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    
    // Advanced: Infinite scrolling
    getInfinitePosts: builder.query({
      query: ({ page = 1, limit = 10 }) => `/posts?page=${page}&limit=${limit}`,
      serializeQueryArgs: ({ endpointName }) => {
        return endpointName;
      },
      merge: (currentCache, newItems) => {
        currentCache.push(...newItems);
      },
      forceRefetch({ currentArg, previousArg }) {
        return currentArg !== previousArg;
      },
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useGetCommentsQuery,
  useAddCommentMutation,
  useGetUsersQuery,
  useGetUserQuery,
  useGetInfinitePostsQuery,
} = apiSlice;

// components/PostsList.js
import { useState } from 'react';
import { useGetPostsQuery } from '../features/api/apiSlice';

const PostsList = () => {
  const [page, setPage] = useState(1);
  const { 
    data: { posts = [], totalCount = 0 } = {},
    isLoading,
    isFetching,
    isError,
    error,
  } = useGetPostsQuery({ page, limit: 10 });

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h2>Posts {isFetching && <span>Refreshing...</span>}</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
      <div>
        <button 
          onClick={() => setPage((p) => p - 1)} 
          disabled={page === 1}
        >
          Previous
        </button>
        <span>Page {page}</span>
        <button 
          onClick={() => setPage((p) => p + 1)}
          disabled={posts.length < 10}
        >
          Next
        </button>
      </div>
    </div>
  );
};

// Enhanced loading states
const PostsListWithStates = () => {
  const {
    data: posts,
    isLoading,
    isSuccess,
    isError,
    error,
    isFetching,
    isUninitialized,
  } = useGetPostsQuery();

  // Handle different states
  if (isUninitialized) {
    return 
Click to load posts
; } if (isLoading) { return <LoadingSpinner />; } if (isError) { if (error.status === 404) { return
Posts not found
; } if (error.status === 401) { return
Please login to view posts
; } return
Error: {error.data?.message || 'Unknown error'}
; } if (isSuccess) { return (
{isFetching &&
Updating...
}
); } return null; }; // Handling mutation errors const CreatePostForm = () => { const [createPost, { isLoading, error }] = useCreatePostMutation(); const [formError, setFormError] = useState(null); const handleSubmit = async (formData) => { try { setFormError(null); await createPost(formData).unwrap(); // Success! Redirect or show success message } catch (err) { if (err.status === 400) { setFormError(err.data.message || 'Invalid input'); } else { setFormError('Failed to create post. Please try again.'); } } }; return (
{formError &&
{formError}
} {error &&
{error.data?.message}
} {/* form fields */}
); };

Testing RTK Query


// Test setup
import { renderHook } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { store } from './store';
import { useGetPostsQuery } from './apiSlice';

// Setup MSW server
const server = setupServer(
  rest.get('/api/posts', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, title: 'Test Post' },
        { id: 2, title: 'Another Post' },
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Test hooks
test('useGetPostsQuery returns posts', async () => {
  const wrapper = ({ children }) => (
    {children}
  );

  const { result, waitForNextUpdate } = renderHook(
    () => useGetPostsQuery(),
    { wrapper }
  );

  // Initial loading state
  expect(result.current.isLoading).toBe(true);
  expect(result.current.data).toBeUndefined();

  // Wait for the query to resolve
  await waitForNextUpdate();

  // Success state
  expect(result.current.isLoading).toBe(false);
  expect(result.current.isSuccess).toBe(true);
  expect(result.current.data).toHaveLength(2);
  expect(result.current.data[0].title).toBe('Test Post');
});

// Test error handling
test('handles API errors', async () => {
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      return res(ctx.status(500), ctx.json({ message: 'Server error' }));
    })
  );

  const wrapper = ({ children }) => (
    {children}
  );

  const { result, waitForNextUpdate } = renderHook(
    () => useGetPostsQuery(),
    { wrapper }
  );

  await waitForNextUpdate();

  expect(result.current.isError).toBe(true);
  expect(result.current.error.status).toBe(500);
});

// Test mutations
test('createPost mutation', async () => {
  const wrapper = ({ children }) => (
    {children}
  );

  server.use(
    rest.post('/api/posts', (req, res, ctx) => {
      return res(ctx.json({ id: 3, ...req.body }));
    })
  );

  const { result, waitForNextUpdate } = renderHook(
    () => useCreatePostMutation(),
    { wrapper }
  );

  const [createPost] = result.current;

  // Trigger mutation
  createPost({ title: 'New Post' });

  await waitForNextUpdate();

  expect(result.current[1].isSuccess).toBe(true);
  expect(result.current[1].data).toEqual({
    id: 3,
    title: 'New Post',
  });
});
            

Best Practices

Do's

  • Use tags for cache invalidation
  • Transform responses for better data shape
  • Handle loading and error states properly
  • Use TypeScript for better type safety
  • Implement optimistic updates for better UX
  • Use proper error boundaries

Don'ts

  • Don't store derived data - compute it from cache
  • Don't manually manage loading states
  • Don't fetch data in useEffect - use RTK Query hooks
  • Don't ignore error handling
  • Don't duplicate data across slices

Performance Tips

  • Use selective subscriptions to avoid unnecessary re-renders
  • Configure appropriate cache lifetimes
  • Implement pagination for large data sets
  • Use batch queries when possible
  • Consider using normalized cache for complex data

Practice Exercise

Task: Create a Todo API with RTK Query

Build a complete Todo API service with the following features:

  • Fetch all todos
  • Create new todos
  • Update todo status
  • Delete todos
  • Filter todos by status
  • Implement optimistic updates

// TODO: Create Todo API slice
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const todoApi = createApi({
  reducerPath: 'todoApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo'],
  endpoints: (builder) => ({
    // TODO: Implement endpoints
    getTodos: builder.query({
      // Implementation here
    }),
    addTodo: builder.mutation({
      // Implementation here
    }),
    updateTodo: builder.mutation({
      // Implementation here
    }),
    deleteTodo: builder.mutation({
      // Implementation here
    }),
  }),
});

// TODO: Export hooks
export const {
  // Export hooks here
} = todoApi;

// TODO: Create components that use the API
const TodoList = () => {
  // Implementation here
};

const AddTodoForm = () => {
  // Implementation here
};

const TodoItem = ({ todo }) => {
  // Implementation here
};
        

Additional Resources