Learn Redux in 10 DaysDay 6: Advanced RTK Query
Chapter 6Learn Redux in 10 Days

Day 6: Advanced RTK Query

What You'll Learn Today

  • Cache invalidation with tags (providesTags, invalidatesTags)
  • Tag types and tag IDs for granular invalidation
  • Optimistic updates with onQueryStarted and updateQueryData
  • Pessimistic update patterns
  • Polling with pollingInterval
  • Prefetching with usePrefetch
  • Pagination patterns
  • Transforming responses with transformResponse
  • Custom baseQuery for authentication
  • Code splitting with injectEndpoints

Cache Invalidation with Tags

The automatic caching we learned on Day 5 has one challenge: after modifying data (create, update, delete), the cached list data becomes stale. The tag system solves this problem.

sequenceDiagram
    participant UI as UI Component
    participant Cache as RTK Query Cache
    participant API as API Server

    UI->>Cache: useGetPostsQuery()
    Cache->>API: GET /posts
    API-->>Cache: posts data
    Cache-->>UI: Return data
    Note over Cache: Tag: ['Post'] assigned

    UI->>Cache: useCreatePostMutation()
    Cache->>API: POST /posts
    API-->>Cache: new post
    Note over Cache: Invalidate tag 'Post'

    Cache->>API: GET /posts (auto re-fetch)
    API-->>Cache: updated posts data
    Cache-->>UI: Return fresh data

Basic Tag Configuration

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

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  // Declare tag types used by this API
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      // Tags this query provides
      providesTags: ['Post'],
    }),

    getPostById: builder.query({
      query: (id) => `/posts/${id}`,
      // Provide a tag with a specific ID
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),

    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      // Tags this mutation invalidates
      invalidatesTags: ['Post'],
    }),

    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
      // Only invalidate the specific 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 }],
    }),
  }),
});
TypeScript version
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      invalidatesTags: ['Post'],
    }),
    updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
    deletePost: builder.mutation<void, number>({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
});

Granular Invalidation with Tag IDs

By assigning IDs to tags, you can re-fetch only specific items rather than the entire list.

flowchart TB
    subgraph Tags["Tag Structure"]
        T1["{ type: 'Post', id: 'LIST' }"]
        T2["{ type: 'Post', id: 1 }"]
        T3["{ type: 'Post', id: 2 }"]
        T4["{ type: 'Post', id: 3 }"]
    end

    subgraph Invalidation["Invalidation Patterns"]
        I1["Invalidate 'Post'<br/>β†’ All Post tags invalidated"]
        I2["Invalidate { type: 'Post', id: 1 }<br/>β†’ Only ID:1 re-fetched"]
        I3["Invalidate { type: 'Post', id: 'LIST' }<br/>β†’ Only list re-fetched"]
    end

    style Tags fill:#3b82f6,color:#fff
    style Invalidation fill:#f59e0b,color:#fff

Advanced Tag Patterns

endpoints: (builder) => ({
  getPosts: builder.query({
    query: () => '/posts',
    // Provide individual tags for each post plus a list tag
    providesTags: (result) =>
      result
        ? [
            // Individual tag for each post
            ...result.map(({ id }) => ({ type: 'Post', id })),
            // Tag for the entire list
            { type: 'Post', id: 'LIST' },
          ]
        : [{ type: 'Post', id: 'LIST' }],
  }),

  createPost: builder.mutation({
    query: (newPost) => ({
      url: '/posts',
      method: 'POST',
      body: newPost,
    }),
    // On create, only re-fetch the list
    invalidatesTags: [{ type: 'Post', id: 'LIST' }],
  }),

  updatePost: builder.mutation({
    query: ({ id, ...patch }) => ({
      url: `/posts/${id}`,
      method: 'PUT',
      body: patch,
    }),
    // On update, only re-fetch the specific ID
    invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
  }),

  deletePost: builder.mutation({
    query: (id) => ({
      url: `/posts/${id}`,
      method: 'DELETE',
    }),
    // On delete, invalidate both the item and the list
    invalidatesTags: (result, error, id) => [
      { type: 'Post', id },
      { type: 'Post', id: 'LIST' },
    ],
  }),
}),
TypeScript version
endpoints: (builder) => ({
  getPosts: builder.query<Post[], void>({
    query: () => '/posts',
    providesTags: (result) =>
      result
        ? [
            ...result.map(({ id }) => ({ type: 'Post' as const, id })),
            { type: 'Post' as const, id: 'LIST' },
          ]
        : [{ type: 'Post' as const, id: 'LIST' }],
  }),

  createPost: builder.mutation<Post, Omit<Post, 'id'>>({
    query: (newPost) => ({
      url: '/posts',
      method: 'POST',
      body: newPost,
    }),
    invalidatesTags: [{ type: 'Post', id: 'LIST' }],
  }),

  updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
    query: ({ id, ...patch }) => ({
      url: `/posts/${id}`,
      method: 'PUT',
      body: patch,
    }),
    invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
  }),

  deletePost: builder.mutation<void, number>({
    query: (id) => ({
      url: `/posts/${id}`,
      method: 'DELETE',
    }),
    invalidatesTags: (result, error, id) => [
      { type: 'Post', id },
      { type: 'Post', id: 'LIST' },
    ],
  }),
}),

Multiple Tag Types

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'Comment', 'User'],
  endpoints: (builder) => ({
    getPostWithComments: builder.query({
      query: (postId) => `/posts/${postId}?_embed=comments`,
      // Provide multiple tag types
      providesTags: (result, error, postId) => [
        { type: 'Post', id: postId },
        { type: 'Comment', id: `POST_${postId}` },
      ],
    }),

    addComment: builder.mutation({
      query: ({ postId, ...comment }) => ({
        url: '/comments',
        method: 'POST',
        body: { postId, ...comment },
      }),
      // Invalidate comments for the related post
      invalidatesTags: (result, error, { postId }) => [
        { type: 'Comment', id: `POST_${postId}` },
      ],
    }),
  }),
});

Optimistic Updates

Optimistic updates instantly update the UI without waiting for the server response. This dramatically improves the user experience.

sequenceDiagram
    participant UI as UI
    participant Cache as Cache
    participant API as API

    UI->>Cache: Toggle todo completion
    Cache->>UI: Update UI instantly (optimistic)
    Cache->>API: PUT /todos/1

    alt Success
        API-->>Cache: 200 OK
        Note over Cache: Cache already up to date
    else Failure
        API-->>Cache: Error
        Cache->>UI: Revert to original state (rollback)
    end

Implementing Optimistic Updates

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo'],
  endpoints: (builder) => ({
    getTodos: builder.query({
      query: () => '/todos',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo', id })),
              { type: 'Todo', id: 'LIST' },
            ]
          : [{ type: 'Todo', id: 'LIST' }],
    }),

    toggleTodo: builder.mutation({
      query: ({ id, completed }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: { completed },
      }),

      // Optimistic update
      async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
        // 1. Immediately update the cache
        const patchResult = dispatch(
          api.util.updateQueryData('getTodos', undefined, (draft) => {
            const todo = draft.find((t) => t.id === id);
            if (todo) {
              todo.completed = completed;
            }
          })
        );

        try {
          // 2. Wait for the server response
          await queryFulfilled;
        } catch {
          // 3. Rollback on error
          patchResult.undo();
        }
      },
    }),
  }),
});
TypeScript version
interface Todo {
  id: number;
  title: string;
  completed: boolean;
  userId: number;
}

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo'],
  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo' as const, id })),
              { type: 'Todo' as const, id: 'LIST' },
            ]
          : [{ type: 'Todo' as const, id: 'LIST' }],
    }),

    toggleTodo: builder.mutation<Todo, { id: number; completed: boolean }>({
      query: ({ id, completed }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: { completed },
      }),
      async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
        const patchResult = dispatch(
          api.util.updateQueryData('getTodos', undefined, (draft) => {
            const todo = draft.find((t) => t.id === id);
            if (todo) {
              todo.completed = completed;
            }
          })
        );
        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
        }
      },
    }),
  }),
});

Todo Component with Optimistic Updates

function TodoList() {
  const { data: todos } = useGetTodosQuery();
  const [toggleTodo] = useToggleTodoMutation();

  return (
    <ul>
      {todos?.map((todo) => (
        <li
          key={todo.id}
          style={{
            textDecoration: todo.completed ? 'line-through' : 'none',
            cursor: 'pointer',
          }}
          onClick={() =>
            toggleTodo({ id: todo.id, completed: !todo.completed })
          }
        >
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

Pessimistic Updates

Pessimistic updates wait for the server response before updating the cache. Use this when data integrity is critical.

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: ['Post'],
    }),

    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),

      // Pessimistic update: update cache after server responds
      async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
        try {
          const { data: createdPost } = await queryFulfilled;
          // Update cache with server-returned data
          dispatch(
            api.util.updateQueryData('getPosts', undefined, (draft) => {
              draft.push(createdPost);
            })
          );
        } catch {
          // No action needed β€” cache was never modified
        }
      },
    }),
  }),
});
TypeScript version
const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),

    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
        try {
          const { data: createdPost } = await queryFulfilled;
          dispatch(
            api.util.updateQueryData('getPosts', undefined, (draft) => {
              draft.push(createdPost);
            })
          );
        } catch {
          // No rollback needed
        }
      },
    }),
  }),
});

When to Use Optimistic vs Pessimistic Updates

Strategy UI Responsiveness Data Integrity Best For
Optimistic Instant Risk of rollback Likes, toggles, reordering
Pessimistic Slower (waits) Safe Payments, critical data creation
Tag invalidation Slower (re-fetch) Safe General CRUD operations

Polling

Use pollingInterval to automatically re-fetch data at regular intervals. This is useful for real-time dashboards.

function LiveDashboard() {
  // Auto-refresh data every 30 seconds
  const { data: stats, isLoading } = useGetDashboardStatsQuery(undefined, {
    pollingInterval: 30000, // 30 seconds
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h2>Dashboard</h2>
      <div>Active Users: {stats?.activeUsers}</div>
      <div>Today's Revenue: ${stats?.todayRevenue?.toLocaleString()}</div>
      <div>Orders: {stats?.orderCount}</div>
    </div>
  );
}

Controlling Polling

function PollingControl() {
  const [pollingInterval, setPollingInterval] = useState(0);

  const { data } = useGetNotificationsQuery(undefined, {
    // Passing 0 stops polling
    pollingInterval,
    // Skip polling when the window is not focused
    skipPollingIfUnfocused: true,
  });

  return (
    <div>
      <button onClick={() => setPollingInterval(5000)}>Every 5s</button>
      <button onClick={() => setPollingInterval(30000)}>Every 30s</button>
      <button onClick={() => setPollingInterval(0)}>Stop Polling</button>
    </div>
  );
}

Complete Real-Time Dashboard Example

// features/api/dashboardApi.js
const dashboardApi = createApi({
  reducerPath: 'dashboardApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Stats'],
  endpoints: (builder) => ({
    getDashboardStats: builder.query({
      query: () => '/dashboard/stats',
      providesTags: ['Stats'],
    }),

    getRecentOrders: builder.query({
      query: () => '/dashboard/orders/recent',
      providesTags: ['Stats'],
    }),

    getAlerts: builder.query({
      query: () => '/dashboard/alerts',
    }),
  }),
});

export const {
  useGetDashboardStatsQuery,
  useGetRecentOrdersQuery,
  useGetAlertsQuery,
} = dashboardApi;

// Dashboard.jsx
function Dashboard() {
  const { data: stats } = useGetDashboardStatsQuery(undefined, {
    pollingInterval: 30000,
  });

  const { data: orders } = useGetRecentOrdersQuery(undefined, {
    pollingInterval: 10000,
  });

  const { data: alerts } = useGetAlertsQuery(undefined, {
    pollingInterval: 5000,
  });

  return (
    <div>
      <StatsCards stats={stats} />
      <OrderTable orders={orders} />
      <AlertBanner alerts={alerts} />
    </div>
  );
}

Prefetching

Use usePrefetch to fetch data before the user needs it.

function PostList() {
  const { data: posts } = useGetPostsQuery();
  // Get the prefetch function
  const prefetchPost = usePrefetch('getPostById');

  return (
    <ul>
      {posts?.map((post) => (
        <li
          key={post.id}
          // Prefetch on mouse hover
          onMouseEnter={() => prefetchPost(post.id)}
        >
          <Link to={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Prefetch Options

function PostList() {
  const prefetchPost = usePrefetch('getPostById', {
    // Only prefetch if data is older than 60 seconds
    ifOlderThan: 60,
  });

  // Or use force to always prefetch
  const prefetchPostForce = usePrefetch('getPostById', {
    force: true,
  });

  return (
    <ul>
      {posts?.map((post) => (
        <li
          key={post.id}
          onMouseEnter={() => prefetchPost(post.id)}
          onFocus={() => prefetchPost(post.id)}
        >
          <Link to={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Pagination

Here is a pattern for implementing pagination with RTK Query.

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: ({ page = 1, limit = 10 }) =>
        `/posts?_page=${page}&_limit=${limit}`,
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post', id })),
              { type: 'Post', id: 'PARTIAL-LIST' },
            ]
          : [{ type: 'Post', id: 'PARTIAL-LIST' }],
    }),
  }),
});

Pagination Component

function PaginatedPosts() {
  const [page, setPage] = useState(1);
  const { data: posts, isLoading, isFetching } = useGetPostsQuery({
    page,
    limit: 10,
  });
  const prefetchPosts = usePrefetch('getPosts');

  return (
    <div>
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <div style={{ opacity: isFetching ? 0.5 : 1 }}>
          {posts?.map((post) => (
            <div key={post.id}>
              <h3>{post.title}</h3>
              <p>{post.body}</p>
            </div>
          ))}
        </div>
      )}

      <div>
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          Previous
        </button>

        <span>Page {page}</span>

        <button
          onClick={() => setPage((p) => p + 1)}
          // Prefetch the next page
          onMouseEnter={() =>
            prefetchPosts({ page: page + 1, limit: 10 })
          }
        >
          Next
        </button>
      </div>
    </div>
  );
}

transformResponse

You can transform the server response before it is stored in the cache.

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    // Extract only needed fields
    getUsers: builder.query({
      query: () => '/users',
      transformResponse: (response) =>
        response.map((user) => ({
          id: user.id,
          name: user.name,
          email: user.email,
        })),
    }),

    // Enrich response data
    getPostComments: builder.query({
      query: (postId) => `/posts/${postId}/comments`,
      transformResponse: (response) =>
        response.map((comment) => ({
          ...comment,
          shortBody: comment.body.substring(0, 100),
        })),
    }),

    // Sort or normalize
    getSortedPosts: builder.query({
      query: () => '/posts',
      transformResponse: (response) =>
        [...response].sort((a, b) =>
          a.title.localeCompare(b.title)
        ),
    }),
  }),
});
TypeScript version
interface UserRaw {
  id: number;
  name: string;
  email: string;
  phone: string;
  website: string;
  company: { name: string };
  address: { city: string };
}

interface UserSimple {
  id: number;
  name: string;
  email: string;
}

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUsers: builder.query<UserSimple[], void>({
      query: () => '/users',
      transformResponse: (response: UserRaw[]) =>
        response.map((user) => ({
          id: user.id,
          name: user.name,
          email: user.email,
        })),
    }),
  }),
});

Custom baseQuery

Use a custom baseQuery for shared authentication and error handling logic.

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

// baseQuery with token refresh
const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers, { getState }) => {
    const token = getState().auth.accessToken;
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }
    return headers;
  },
});

const baseQueryWithReauth = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions);

  if (result?.error?.status === 401) {
    // Attempt to refresh the token
    const refreshResult = await baseQuery(
      { url: '/auth/refresh', method: 'POST' },
      api,
      extraOptions
    );

    if (refreshResult?.data) {
      // Store the new token
      api.dispatch(setCredentials(refreshResult.data));
      // Retry the original request
      result = await baseQuery(args, api, extraOptions);
    } else {
      // Refresh failed β€” log out
      api.dispatch(logout());
    }
  }

  return result;
};

export const api = createApi({
  baseQuery: baseQueryWithReauth,
  tagTypes: ['Post', 'User'],
  endpoints: () => ({}),
});
TypeScript version
import {
  fetchBaseQuery,
  BaseQueryFn,
  FetchArgs,
  FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import { RootState } from '../../store';
import { setCredentials, logout } from '../auth/authSlice';

const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as RootState).auth.accessToken;
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }
    return headers;
  },
});

const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions);

  if (result?.error?.status === 401) {
    const refreshResult = await baseQuery(
      { url: '/auth/refresh', method: 'POST' },
      api,
      extraOptions
    );

    if (refreshResult?.data) {
      api.dispatch(setCredentials(refreshResult.data as { accessToken: string }));
      result = await baseQuery(args, api, extraOptions);
    } else {
      api.dispatch(logout());
    }
  }

  return result;
};

export const api = createApi({
  baseQuery: baseQueryWithReauth,
  tagTypes: ['Post', 'User'],
  endpoints: () => ({}),
});

Code Splitting with injectEndpoints

In large applications, defining all endpoints in a single file is impractical. Use injectEndpoints to split endpoints across files.

// features/api/baseApi.js - Shared API definition
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const baseApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'User', 'Comment'],
  endpoints: () => ({}),
});
// features/api/postsApi.js - Post endpoints
import { baseApi } from './baseApi';

export const postsApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      invalidatesTags: ['Post'],
    }),
  }),
});

export const { useGetPostsQuery, useCreatePostMutation } = postsApi;
// features/api/usersApi.js - User endpoints
import { baseApi } from './baseApi';

export const usersApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['User'],
    }),
    getUserById: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
  }),
});

export const { useGetUsersQuery, useGetUserByIdQuery } = usersApi;

Store Configuration Uses Only baseApi

// store.js
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from './features/api/baseApi';

export const store = configureStore({
  reducer: {
    [baseApi.reducerPath]: baseApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(baseApi.middleware),
});

Summary

Today we learned advanced RTK Query techniques.

Concept Description
providesTags Cache tags a query provides
invalidatesTags Tags a mutation invalidates
Tag IDs Enable granular cache invalidation
Optimistic updates Update UI instantly before server responds
Pessimistic updates Update cache after server confirms
pollingInterval Automatic periodic data re-fetching
usePrefetch Pre-fetch data before user needs it
transformResponse Transform server responses before caching
Custom baseQuery Shared auth and error handling
injectEndpoints Split endpoints across files

Exercises

  1. Basic: Create an API slice with two tag types β€” Post and Comment β€” where adding a comment only re-fetches the comments for the related post, not all posts.

  2. Intermediate: Implement optimistic updates for a todo app. When editing a todo's title, update the UI immediately and roll back if the server returns an error.

  3. Challenge: Implement a custom baseQuery with automatic token refresh. On a 401 error, use a refresh token to obtain a new access token and retry the original request. Also split the endpoints across multiple files using injectEndpoints.