Skip to main content

πŸ“‘ RTK Query Basics

Most of what a slice-plus-thunk does for network data is the same every time: fire a request, track loading, store the result, handle errors, avoid refetching what you already have. RTK Query is the Redux team's answer β€” a data-fetching and caching layer that writes all of that for you. You describe your endpoints once and get back React hooks, a cache, and automatic re-fetching for free.

Week 6 · Thursday: Redux Toolkit · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what RTK Query automates and when to prefer it over hand-written thunks
  • Define an API slice with createApi and fetchBaseQuery
  • Distinguish query endpoints (reads) from mutation endpoints (writes)
  • Use the auto-generated useXxxQuery and useXxxMutation hooks and their status flags
  • Register the API reducer and middleware in configureStore
  • Keep the UI in sync automatically with providesTags and invalidatesTags

Estimated Time: 70 minutes

Practice: Build a posts API slice with list/detail queries and a create mutation that refreshes the list.

In This Lesson

What is RTK Query?

RTK Query is a data-fetching and caching solution built into Redux Toolkit. It is purpose-built for server state: data that lives on a server, that you fetch, cache, and keep fresh. It generates the thunks, the reducer, the cache, and the React hooks β€” so you stop hand-writing that machinery entirely.

πŸ“‘ The Streaming Service Analogy

Hand-written fetching is like ripping DVDs: you manually download each show, name the files, track which episodes you have, and re-download when something changes. RTK Query is a streaming app β€” you just say "play this," and it fetches, caches, remembers what you've watched, and quietly updates in the background.

  • Endpoints β€” the catalog of things you can request
  • Cache β€” episodes kept ready so you don't re-download
  • Tags & invalidation β€” the "new episode available" signal that refreshes what's stale

Under the hood it's still Redux Toolkit β€” createAsyncThunk and a slice you never have to write. You get a thin declarative layer on top.

Before & After

Compare the amount of code needed to fetch and display one user. The manual version is everything you built last lesson; the RTK Query version is a few lines.

graph LR A["Manual fetching"] --> B["Write a thunk"] A --> C["Track loading state"] A --> D["Track error state"] A --> E["Cache & refetch by hand"] F["RTK Query"] --> G["Auto-generated hooks"] F --> H["Built-in loading flags"] F --> I["Built-in error flags"] F --> J["Automatic cache"] B --> G C --> H D --> I E --> J

Before β€” manual thunk + reducer + component

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

// reducer (omitted β€” the usual switch with loading/error/user)

// component
const UserProfile = ({ userId }) => {
  const dispatch = useDispatch();
  const { user, loading, error } = useSelector((s) => s.user);
  useEffect(() => { dispatch(fetchUser(userId)); }, [dispatch, userId]);
  if (loading) return <div>Loading...</div>;
  if (error)   return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
};

After β€” one endpoint, one hook

// 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;   // hook generated automatically

// component
const UserProfile = ({ userId }) => {
  const { data: user, isLoading, error } = useGetUserQuery(userId);
  if (isLoading) return <div>Loading...</div>;
  if (error)     return <div>Error loading user</div>;
  return <div>{user.name}</div>;
};

βœ… Everything the second version gives you free

The thunk, the reducer, the cache, deduped requests (two components asking for the same user share one fetch), and the isLoading / error flags β€” all generated from that single getUser endpoint.

Creating an API Slice

You define one API slice per base URL with createApi. It takes a reducerPath (where its cache lives in the store), a baseQuery (how requests are made), and an endpoints builder describing each operation.

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

export const apiSlice = createApi({
  reducerPath: 'api',                                    // state.api holds the cache
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),        // a thin fetch wrapper
  tagTypes: ['Post'],                                    // cache tag names (see below)
  endpoints: (builder) => ({
    // QUERY endpoints β€” read data
    getPosts: builder.query({
      query: () => '/posts'
    }),
    getPost: builder.query({
      query: (postId) => `/posts/${postId}`
    }),

    // MUTATION endpoints β€” write 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
      })
    })
  })
});

// Hooks are generated from endpoint names: use + Name + Query/Mutation
export const {
  useGetPostsQuery,
  useGetPostQuery,
  useAddNewPostMutation,
  useUpdatePostMutation
} = apiSlice;

πŸ’‘ fetchBaseQuery

A lightweight wrapper around the browser fetch API. Set a baseUrl once and each endpoint's query returns just the path (a string) or a request object ({ url, method, body }). You can also inject headers, e.g. an auth token, via prepareHeaders.

Store Setup

The API slice generates both a reducer and a middleware. Add the reducer under its reducerPath, and concat the middleware onto the defaults β€” the middleware is what powers caching, invalidation, and polling.

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

export const store = configureStore({
  reducer: {
    [apiSlice.reducerPath]: apiSlice.reducer,   // β†’ state.api
    // ...your other slice reducers
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware)
});

⚠️ Don't forget the middleware

If you add the reducer but forget .concat(apiSlice.middleware), queries still run once but caching, tag invalidation, re-fetching, and polling silently stop working. It's the single most common RTK Query setup mistake.

Query Endpoints & Hooks

Each query endpoint generates a hook. Call it in a component and it returns the data plus a rich set of status flags. The hook automatically starts the fetch on mount and subscribes the component to that cache entry.

FieldMeaning
dataThe cached response (undefined until it arrives)
isLoadingtrue only on the first load, when there's no cached data yet
isFetchingtrue whenever a request is in flight, including background re-fetches
isSuccess / isErrorTerminal status flags
errorThe error object when isError
refetch()Manually trigger a re-fetch

Basic query

const PostsList = () => {
  const { data: posts, isLoading, isError, error } = useGetPostsQuery();

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

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};

Query with an argument

Whatever you pass to the hook becomes the argument to the endpoint's query function β€” and part of the cache key, so each id is cached separately.

const SinglePost = ({ postId }) => {
  const { data: post, isLoading } = useGetPostQuery(postId);
  if (isLoading) return <div>Loading...</div>;
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </article>
  );
};

Conditional fetching with skip

const UserPosts = ({ userId }) => {
  // Don't fire the query until we actually have a userId
  const { data: posts } = useGetPostsQuery(userId, { skip: !userId });
  return posts ? <PostsList posts={posts} /> : null;
};

πŸ’‘ isLoading vs isFetching

Use isLoading to show a full-page spinner on the very first load. Use isFetching to show a subtle "refreshing…" indicator during a background re-fetch, while the stale data stays visible. Reaching for isLoading on re-fetches makes the UI flash empty.

Mutation Endpoints

Mutations change server data (POST/PATCH/DELETE). Their hook returns a tuple: a trigger function you call to fire the request, and a result object with status flags.

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

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      // .unwrap() throws on failure so try/catch works
      await addNewPost({ title, content }).unwrap();
      setTitle('');
      setContent('');
    } catch (err) {
      console.error('Failed to save the post:', err);
    }
  };

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

The shape of a mutation hook

const [trigger, result] = useAddNewPostMutation();
// trigger(arg)         β†’ fires the request, returns a promise (.unwrap() available)
// result.isLoading     β†’ request in flight
// result.isSuccess     β†’ completed OK
// result.error         β†’ set on failure

The Cache & Tags

Here's the feature that makes RTK Query feel magical: after you add a post, the list of posts updates on its own β€” no manual dispatch, no refetch call. That's cache invalidation via tags.

The idea is a publish/subscribe contract between queries and mutations:

  • A query provides tags β€” "this cached data is tagged Post."
  • A mutation invalidates tags β€” "I changed Post data; anything tagged Post is now stale."
  • RTK Query automatically re-fetches every mounted query whose tags were invalidated.
sequenceDiagram participant U as Component participant Q as useGetPostsQuery participant C as RTKQ Cache participant M as useAddNewPostMutation U->>Q: mount Q->>C: fetch, provides tag Post/LIST C-->>Q: posts (cached) U->>M: addNewPost(newPost) M->>C: success, invalidates Post/LIST C->>Q: tag stale β†’ auto re-fetch Q-->>U: fresh list, no manual code
const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      // Tag the list, and each post by id
      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 }]
    }),
    addNewPost: builder.mutation({
      query: (body) => ({ url: '/posts', method: 'POST', body }),
      // New post β†’ the LIST is stale β†’ getPosts re-fetches automatically
      invalidatesTags: [{ type: 'Post', id: 'LIST' }]
    }),
    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({ url: `/posts/${id}`, method: 'PATCH', body: patch }),
      // Editing post 5 β†’ invalidate exactly that post's cached data
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]
    })
  })
});

βœ… Why the LIST id?

Tagging the collection with a special id: 'LIST' lets a new-item mutation invalidate the list without touching every individual post. Editing one post invalidates only { type: 'Post', id }, so unrelated cache entries stay put.

Practice & Quiz

πŸ‹οΈ Exercise 1: A todos API slice

Goal: Create an API slice with a getTodos query and an addTodo mutation, wired so adding a todo refreshes the list automatically.

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

export const todoApi = createApi({
  reducerPath: 'todoApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo'],
  endpoints: (builder) => ({
    // TODO: getTodos query (provides Todo/LIST)
    // TODO: addTodo mutation (invalidates Todo/LIST)
  })
});

// TODO: export the generated hooks
πŸ’‘ Hint

The query providesTags: [{ type: 'Todo', id: 'LIST' }]; the mutation invalidatesTags: [{ type: 'Todo', id: 'LIST' }]. Hooks are useGetTodosQuery and useAddTodoMutation.

βœ… Solution
endpoints: (builder) => ({
  getTodos: builder.query({
    query: () => '/todos',
    providesTags: [{ type: 'Todo', id: 'LIST' }]
  }),
  addTodo: builder.mutation({
    query: (todo) => ({ url: '/todos', method: 'POST', body: todo }),
    invalidatesTags: [{ type: 'Todo', id: 'LIST' }]
  })
})

export const { useGetTodosQuery, useAddTodoMutation } = todoApi;

πŸ‹οΈ Exercise 2: Fix the flashing spinner

Goal: This list flashes an empty "Loading..." every time it re-fetches in the background. Change one flag so the stale list stays visible during re-fetches.

const { data: posts, isLoading } = useGetPostsQuery();
if (isLoading) return <Spinner />;   // fires on every background re-fetch too
βœ… Solution

Keep isLoading for the true first load, and use isFetching only for a subtle inline indicator β€” never blank the screen on a re-fetch.

const { data: posts, isLoading, isFetching } = useGetPostsQuery();
if (isLoading) return <Spinner />;   // first load only
return (
  <div>
    {isFetching && <small>Refreshing...</small>}
    <PostsList posts={posts} />
  </div>
);

🎯 Quick Quiz

Question 1: What is the difference between a query and a mutation endpoint?

Question 2: You added the API reducer but the cache never invalidates. What's missing?

Question 3: A mutation's invalidatesTags: [{ type: 'Post', id: 'LIST' }] causes what?

Best Practices & Pitfalls

βœ… Do

  • Define one API slice per base URL and add all endpoints to it
  • Always .concat(apiSlice.middleware) in configureStore
  • Use providesTags / invalidatesTags so the UI stays in sync automatically
  • Use isLoading for first load, isFetching for background refreshes
  • Let RTK Query own server state; keep Redux slices for genuine client state (UI, auth)

❌ Don't

  • Fetch RTK Query data inside useEffect β€” the hook already does that
  • Copy query results into a separate slice β€” read them from the cache via the hook
  • Manually track loading/error state β€” the hook exposes it
  • Create a new createApi per component β€” endpoints belong on the shared slice

πŸ’‘ Transforming responses

When the server's shape isn't what your UI wants, reshape it once at the endpoint with transformResponse instead of in every component:

getPosts: builder.query({
  query: () => '/posts',
  transformResponse: (response) =>
    [...response].sort((a, b) => new Date(b.date) - new Date(a.date))
})

Summary

πŸŽ‰ Key Takeaways

  • RTK Query generates the thunks, reducer, cache, and React hooks for server data
  • createApi + fetchBaseQuery define one API slice per base URL
  • Query endpoints read (and cache); mutation endpoints write
  • Endpoints auto-generate useXxxQuery / useXxxMutation hooks with rich status flags
  • Register the reducer at reducerPath and concat the middleware β€” don't skip it
  • providesTags + invalidatesTags keep the UI in sync automatically

πŸ“š Additional Resources

πŸš€ What's Next?

RTK Query caches server data flat by endpoint, but complex apps also need to manage relational client state efficiently. Next up: Normalized state structure β€” organizing collections as id-keyed lookups so updates stay fast and duplication-free.

πŸŽ‰ Data fetching, solved!

You've reached the modern peak of Redux: describe your endpoints, and the cache, hooks, and re-fetching take care of themselves.