Learn Redux in 10 DaysDay 5: RTK Query Basics
books.chapter 5Learn Redux in 10 Days

Day 5: RTK Query Basics

What You'll Learn Today

  • What RTK Query is and why it replaces manual data fetching
  • Setting up createApi and fetchBaseQuery
  • Defining query endpoints (GET operations)
  • Defining mutation endpoints (POST/PUT/DELETE)
  • Auto-generated hooks: useGetXQuery, useXMutation
  • Adding the API slice to the store with middleware and reducerPath
  • Automatic caching behavior and request deduplication
  • Loading, error, and success states from hooks

What is RTK Query

RTK Query is a data fetching and caching solution built into Redux Toolkit. It dramatically simplifies the traditional Redux data fetching patterns.

flowchart LR
    subgraph Before["Traditional Approach"]
        A1["createAsyncThunk"] --> A2["extraReducers"]
        A2 --> A3["Loading state mgmt"]
        A3 --> A4["Error handling"]
        A4 --> A5["Cache management"]
        A5 --> A6["Re-fetch logic"]
    end

    subgraph After["RTK Query"]
        B1["createApi"] --> B2["Auto-generated hooks"]
        B2 --> B3["Everything managed automatically"]
    end

    style Before fill:#ef4444,color:#fff
    style After fill:#22c55e,color:#fff

Why Use RTK Query

When fetching data with createAsyncThunk, you need a lot of boilerplate code.

// ❌ Traditional approach: lots of boilerplate
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// Define thunks
const fetchPosts = createAsyncThunk('posts/fetchAll', async () => {
  const response = await fetch('/api/posts');
  return response.json();
});

const fetchPostById = createAsyncThunk('posts/fetchById', async (id) => {
  const response = await fetch(`/api/posts/${id}`);
  return response.json();
});

const createPost = createAsyncThunk('posts/create', async (newPost) => {
  const response = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newPost),
  });
  return response.json();
});

// Define slice
const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    currentPost: null,
    loading: false,
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      })
      // Same pattern for fetchPostById, createPost...
  },
});
TypeScript version
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

interface Post {
  id: number;
  title: string;
  body: string;
}

interface PostsState {
  items: Post[];
  currentPost: Post | null;
  loading: boolean;
  error: string | null;
}

const fetchPosts = createAsyncThunk<Post[]>('posts/fetchAll', async () => {
  const response = await fetch('/api/posts');
  return response.json();
});

const fetchPostById = createAsyncThunk<Post, number>(
  'posts/fetchById',
  async (id) => {
    const response = await fetch(`/api/posts/${id}`);
    return response.json();
  }
);

const createPost = createAsyncThunk<Post, Omit<Post, 'id'>>(
  'posts/create',
  async (newPost) => {
    const response = await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newPost),
    });
    return response.json();
  }
);

const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    currentPost: null,
    loading: false,
    error: null,
  } as PostsState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchPosts.fulfilled, (state, action: PayloadAction<Post[]>) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message ?? 'Unknown error';
      });
  },
});

With RTK Query, all of this becomes dramatically simpler.


createApi and fetchBaseQuery

The core of RTK Query is the createApi function. You define a base URL and endpoints, and React hooks are auto-generated.

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

export const postsApi = createApi({
  // Specify the reducer path in the store
  reducerPath: 'postsApi',

  // Configure the base URL
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),

  // Define endpoints
  endpoints: (builder) => ({
    // Query and mutation endpoints go here
  }),
});
TypeScript version
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  endpoints: (builder) => ({
    // endpoints will be defined here
  }),
});

fetchBaseQuery Configuration Options

fetchBaseQuery is a wrapper around the fetch API with common configuration options.

const baseQuery = fetchBaseQuery({
  baseUrl: 'https://api.example.com',

  // Add headers to every request
  prepareHeaders: (headers, { getState }) => {
    const token = getState().auth.token;
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }
    return headers;
  },

  // Timeout in milliseconds
  timeout: 10000,
});
TypeScript version
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { RootState } from '../../store';

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

Defining Query Endpoints

Query endpoints are used for GET operations (fetching data). They are defined with builder.query.

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  endpoints: (builder) => ({
    // Fetch all posts
    getPosts: builder.query({
      query: () => '/posts',
    }),

    // Fetch a single post by ID
    getPostById: builder.query({
      query: (id) => `/posts/${id}`,
    }),

    // Fetch posts by user (with query parameter)
    getPostsByUser: builder.query({
      query: (userId) => `/posts?userId=${userId}`,
    }),
  }),
});

// Hooks are auto-generated
export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useGetPostsByUserQuery,
} = postsApi;
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',
  }),
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
    }),
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
    }),
    getPostsByUser: builder.query<Post[], number>({
      query: (userId) => `/posts?userId=${userId}`,
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useGetPostsByUserQuery,
} = postsApi;

Hook Naming Convention

RTK Query auto-generates hook names from endpoint names.

Endpoint Name Generated Hook
getPosts useGetPostsQuery
getPostById useGetPostByIdQuery
createPost useCreatePostMutation
updatePost useUpdatePostMutation
deletePost useDeletePostMutation

Defining Mutation Endpoints

Mutation endpoints are used for POST/PUT/DELETE operations (modifying data). They are defined with builder.mutation.

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  endpoints: (builder) => ({
    // GET: Fetch all posts
    getPosts: builder.query({
      query: () => '/posts',
    }),

    // GET: Fetch a single post
    getPostById: builder.query({
      query: (id) => `/posts/${id}`,
    }),

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

    // PUT: Update a post
    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
    }),

    // DELETE: Delete a post
    deletePost: builder.mutation({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = postsApi;
TypeScript version
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

type NewPost = Omit<Post, 'id'>;
type UpdatePost = Partial<Post> & Pick<Post, 'id'>;

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
    }),
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
    }),
    createPost: builder.mutation<Post, NewPost>({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
    }),
    updatePost: builder.mutation<Post, UpdatePost>({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
    }),
    deletePost: builder.mutation<{ success: boolean }, number>({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = postsApi;

Adding the API Slice to the Store

The API slice created with createApi needs both a reducer and middleware added to the store.

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

export const store = configureStore({
  reducer: {
    // Add the API reducer (uses reducerPath as the key)
    [postsApi.reducerPath]: postsApi.reducer,
  },
  // Add the API middleware (needed for caching, polling, etc.)
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(postsApi.middleware),
});
TypeScript version
import { configureStore } from '@reduxjs/toolkit';
import { postsApi } from './features/api/postsApi';

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

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Why is Middleware Needed

The RTK Query middleware provides these essential features.

flowchart TB
    subgraph Middleware["RTK Query Middleware"]
        M1["Cache lifetime management"]
        M2["Request deduplication"]
        M3["Polling control"]
        M4["Reference counting"]
        M5["Automatic cleanup of unused cache"]
    end

    style Middleware fill:#8b5cf6,color:#fff

Automatic Caching Behavior

One of RTK Query's most powerful features is automatic caching. Duplicate requests for the same data are automatically deduplicated.

sequenceDiagram
    participant C1 as Component A
    participant C2 as Component B
    participant RTK as RTK Query Cache
    participant API as API Server

    C1->>RTK: useGetPostsQuery()
    RTK->>API: GET /posts
    API-->>RTK: [posts data]
    RTK-->>C1: Return data

    Note over RTK: Stored in cache

    C2->>RTK: useGetPostsQuery()
    RTK-->>C2: Return from cache instantly
    Note over C2: No API request!

Cache Lifecycle

// Component A mounts β†’ data fetching begins
function PostList() {
  const { data, isLoading } = useGetPostsQuery();
  // Even if Component B uses the same query,
  // no additional API request is made
}

// Component B uses the same data
function PostSidebar() {
  const { data } = useGetPostsQuery();
  // Data comes from cache (no API request)
}

The keepUnusedDataFor Option

By default, when all components using a piece of data unmount, the cache is removed after 60 seconds. This duration is configurable.

// Global configuration
const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  // Applied to all endpoints
  keepUnusedDataFor: 300, // 5 minutes
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      // Can also be set per endpoint
      keepUnusedDataFor: 600, // 10 minutes
    }),
  }),
});

Hook Return Values: Loading, Error, and Success States

Query hooks return rich state information.

function PostList() {
  const {
    data,          // The fetched data
    error,         // Error information
    isLoading,     // True during initial load
    isFetching,    // True during any fetch (including re-fetches)
    isSuccess,     // True when data is available
    isError,       // True when an error occurred
    isUninitialized, // True before the request starts
    refetch,       // Function to manually re-fetch
  } = useGetPostsQuery();

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

  return (
    <ul>
      {data?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
TypeScript version
import { useGetPostsQuery } from './features/api/postsApi';

function PostList() {
  const {
    data,
    error,
    isLoading,
    isFetching,
    isSuccess,
    isError,
    refetch,
  } = useGetPostsQuery();

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>An error occurred</p>;

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

isLoading vs isFetching

State isLoading isFetching Description
Initial load true true No data in cache
Re-fetching false true Data in cache, updating in background
Complete false false Data fetched successfully
function PostList() {
  const { data, isLoading, isFetching } = useGetPostsQuery();

  return (
    <div>
      {/* Show spinner only on initial load */}
      {isLoading && <Spinner />}

      {/* Dim content while re-fetching */}
      <div style={{ opacity: isFetching ? 0.5 : 1 }}>
        {data?.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
}

Using Mutation Hooks

Mutation hooks differ from query hooks β€” they return a trigger function and a result object as a tuple.

function CreatePostForm() {
  const [createPost, { isLoading, isSuccess, isError, error }] =
    useCreatePostMutation();

  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await createPost({ title, body, userId: 1 }).unwrap();
      setTitle('');
      setBody('');
      alert('Post created successfully!');
    } catch (err) {
      console.error('Failed to create post:', err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
      />
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Body"
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Creating...' : 'Create Post'}
      </button>
      {isError && <p>Error: {error.message}</p>}
    </form>
  );
}
TypeScript version
import { useState, FormEvent } from 'react';
import { useCreatePostMutation } from './features/api/postsApi';

function CreatePostForm() {
  const [createPost, { isLoading, isError, error }] =
    useCreatePostMutation();

  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    try {
      await createPost({ title, body, userId: 1 }).unwrap();
      setTitle('');
      setBody('');
      alert('Post created successfully!');
    } catch (err) {
      console.error('Failed to create post:', err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
      />
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Body"
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Creating...' : 'Create Post'}
      </button>
      {isError && <p>An error occurred</p>}
    </form>
  );
}

The unwrap() Method

unwrap() treats the mutation result as a Promise β€” it resolves with the data on success and throws on failure.

// Without unwrap()
const result = await createPost(newPost);
if ('data' in result) {
  console.log('Success:', result.data);
} else {
  console.log('Error:', result.error);
}

// With unwrap() (can use try/catch)
try {
  const data = await createPost(newPost).unwrap();
  console.log('Success:', data);
} catch (error) {
  console.log('Error:', error);
}

Practical Example: Complete CRUD Application

Let's combine everything we've learned to build a full CRUD application for posts.

API Slice Definition

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

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
    }),
    getPostById: builder.query({
      query: (id) => `/posts/${id}`,
    }),
    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
    }),
    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
    }),
    deletePost: builder.mutation({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = postsApi;

Post List Component

function PostList() {
  const { data: posts, isLoading, isError, refetch } = useGetPostsQuery();
  const [deletePost] = useDeletePostMutation();

  const handleDelete = async (id) => {
    if (window.confirm('Are you sure you want to delete this post?')) {
      try {
        await deletePost(id).unwrap();
        alert('Post deleted');
      } catch (err) {
        alert('Failed to delete post');
      }
    }
  };

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>An error occurred</div>;

  return (
    <div>
      <h2>Posts</h2>
      <button onClick={refetch}>Refresh</button>
      {posts?.map((post) => (
        <div key={post.id} style={{ border: '1px solid #ccc', padding: '16px', margin: '8px 0' }}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
          <button onClick={() => handleDelete(post.id)}>Delete</button>
        </div>
      ))}
    </div>
  );
}

Post Detail Component

function PostDetail({ postId }) {
  const { data: post, isLoading, isError } = useGetPostByIdQuery(postId);
  const [updatePost, { isLoading: isUpdating }] = useUpdatePostMutation();

  const handleUpdate = async () => {
    try {
      await updatePost({
        id: postId,
        title: post.title + ' (updated)',
      }).unwrap();
      alert('Post updated');
    } catch (err) {
      alert('Failed to update post');
    }
  };

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Post not found</div>;

  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
      <button onClick={handleUpdate} disabled={isUpdating}>
        {isUpdating ? 'Updating...' : 'Update Title'}
      </button>
    </div>
  );
}

Conditional Queries: The skip Option

When you want to skip a query under certain conditions, use the skip option.

function PostDetail({ postId }) {
  // Skip the query when postId is undefined
  const { data, isLoading } = useGetPostByIdQuery(postId, {
    skip: !postId,
  });

  if (!postId) return <p>Please select a post</p>;
  if (isLoading) return <p>Loading...</p>;

  return <div>{data?.title}</div>;
}

createAsyncThunk vs RTK Query Comparison

Feature createAsyncThunk RTK Query
Boilerplate Heavy Minimal
Cache management Manual Automatic
Loading states Manual Automatic
Request deduplication None Automatic
Re-fetching Manual implementation Simple refetch()
Data normalization Manual Automatic (optional)
TypeScript types Manual definition Auto-inferred
Learning curve Low Moderate
Best for General-purpose logic API communication

Summary

Today we learned the fundamentals of RTK Query.

Concept Description
createApi Function to create an API slice
fetchBaseQuery fetch-based HTTP client wrapper
builder.query Define GET request endpoints
builder.mutation Define POST/PUT/DELETE endpoints
Auto-generated hooks useXQuery, useXMutation
Automatic caching Deduplicates identical requests
isLoading True during initial load only
isFetching True during any fetch including re-fetches
unwrap() Promise-based error handling
skip Conditional query execution

Exercises

  1. Basic: Create an API slice using https://jsonplaceholder.typicode.com/users to fetch a user list. Build a component that displays the user list using the useGetUsersQuery hook.

  2. Intermediate: Create a comments CRUD API with the following endpoints:

    • GET /posts/:postId/comments - List comments for a post
    • POST /comments - Create a comment
    • DELETE /comments/:id - Delete a comment
  3. Challenge: Using the skip option, build a component with a dropdown to select a user. When a user is selected, display their posts. When no user is selected, do not send any request.