Redux Toolkit 14 ⚛️ RTK Query Endpoints — Defining automated queries and mutations
An endpoint is the definition of a single data operation inside an API slice. The build.query method defines a read operation, and the build.mutation method defines a write operation. The previous chapter covered the createApi setup and the store integration; this chapter covers the endpoints themselves: the query function, the argument type, the return type, the providesTags and invalidatesTags options, and the lifecycle flags that the generated hooks expose. The endpoint is the unit that generates a hook, a cache entry, and a set of status flags, and the shape of the endpoint definition determines what the hook returns.
The endpoint’s query function receives the argument and the QueryArg type, and it returns either a URL string or an object with the url, method, body, and headers fields . The build.query<ReturnType, ArgType> generic specifies the data type and the argument type, and the generated hook is typed accordingly. The providesTags option tells RTK Query which queries should be refetched when the data changes, and the invalidatesTags option tells RTK Query which queries the mutation affects . The two options are the mechanism that connects the reads and the writes without manual invalidation.
Key point: A query endpoint is for reading data and receives an argument that is used to build the URL. A mutation endpoint is for writing data and receives a body that is sent to the server. The build.query hook returns an object with the data, the isLoading, the isFetching, the isError, and the refetch function. The build.mutation hook returns a tuple with the trigger function and an object with the isLoading, the isError, the data, and the reset function. The two hooks have different shapes, and the component must use them accordingly.
Why endpoints require explicit typing
The data-type problem.
The build.query<Post[], void> generic tells TypeScript that the endpoint returns an array of posts and takes no argument. The generated hook is typed as useGetPostsQuery() with no argument, and the data property is Post[] | undefined . Without the generic, the data is unknown, and the component must cast it. The build.mutation<Post, Pick<Post, 'title'>> generic tells TypeScript that the mutation returns a post and takes an object with a title, and the trigger function is typed accordingly.
The argument-type problem.
The argument type is the second generic parameter. A query that takes an ID is build.query<Post, number>, and the generated hook is called with a number. A query that takes no argument is build.query<Post[], void>, and the hook is called with no argument . The void type is the correct choice for a query with no argument, not undefined, because the hook’s signature is useGetPostsQuery() rather than useGetPostsQuery(undefined).
The tag problem.
The providesTags and invalidatesTags options accept a static array or a function that returns an array. The function form receives the result and the error, and it can return a tag for each item in the result . The 'Post' as const cast is necessary in TypeScript because the tagTypes array is a union of string literals, and the array of tag objects would otherwise be typed as string .
a. The query endpoints
A query endpoint is defined with build.query. The first generic is the data type, and the second is the argument type. The query function returns the URL or the request object.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export interface Post {
id: number
title: string
body: string
}
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
tagTypes: ['Post'],
endpoints: (build) => ({
// Query with no argument
getPosts: build.query<Post[], void>({
query: () => 'posts',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Post' as const, id })), { type: 'Post', id: 'LIST' }]
: [{ type: 'Post', id: 'LIST' }],
}),
// Query with an argument
getPost: build.query<Post, number>({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
})
export const { useGetPostsQuery, useGetPostQuery } = api
The getPosts endpoint takes no argument and returns an array of posts. The providesTags function returns a tag for each post and a tag for the list, which allows the mutation to invalidate the list or a specific post . The getPost endpoint takes an ID and returns a single post. The providesTags function receives the result, the error, and the argument, and it returns the tag for that post . The generated hooks are useGetPostsQuery() and useGetPostQuery(id).
The query function can return an object with the url, the method, and the body when the request needs more than a URL.
getPostsPaginated: build.query<Post[], { page: number; limit: number }>({
query: ({ page, limit }) => ({
url: 'posts',
params: { page, limit },
}),
providesTags: ['Post'],
}),
The params option adds the query string parameters to the URL, which is the standard way to pass pagination and filtering arguments . The providesTags: ['Post'] form is the shorthand for a static tag list, and it is the correct choice when the query does not need to tag individual items.
b. The mutation endpoints
A mutation endpoint is defined with build.mutation. The first generic is the data type that the mutation returns, and the second is the argument type that the trigger function accepts.
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
tagTypes: ['Post'],
endpoints: (build) => ({
getPosts: build.query<Post[], void>({
query: () => 'posts',
providesTags: ['Post'],
}),
addPost: build.mutation<Post, Pick<Post, 'title' | 'body'>>({
query: (body) => ({
url: 'posts',
method: 'POST',
body,
}),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: build.mutation<Post, { id: number; changes: Partial<Post> }>({
query: ({ id, changes }) => ({
url: `posts/${id}`,
method: 'PATCH',
body: changes,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: build.mutation<void, number>({
query: (id) => ({
url: `posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
})
export const {
useGetPostsQuery,
useAddPostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = api
The addPost mutation takes a title and a body, sends a POST request, and invalidates the list tag, which refetches the getPosts query . The updatePost mutation takes an ID and a changes object, sends a PATCH request, and invalidates the tag for that specific post, which refetches the getPost query if it is mounted . The deletePost mutation takes an ID, sends a DELETE request, and invalidates the tag for that post.
The invalidatesTags function form receives the result, the error, and the argument, and it returns the tags to invalidate. The updatePost and deletePost mutations use the function form because the tag depends on the ID argument . The addPost mutation uses the static form because the new post’s ID is not known until the server responds, and the list tag is the correct target.
c. The generated hook shapes and the lifecycle flags
The query hook returns an object with the data, the loading flags, and the refetch function. The mutation hook returns a tuple with the trigger function and the result object.
function PostList() {
// Query hook: object return
const {
data: posts = [],
isLoading,
isFetching,
isError,
error,
refetch,
} = useGetPostsQuery()
// Mutation hook: tuple return
const [addPost, { isLoading: isAdding, isError: isAddError }] = useAddPostMutation()
if (isLoading) return <p>Loading...</p>
if (isError) return <p>Error: {error}</p>
return (
<div>
<button onClick={() => addPost({ title: 'New', body: '...' })} disabled={isAdding}>
Add
</button>
<button onClick={() => refetch()}>Refresh</button>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
The query hook’s isLoading is true only on the initial load, and isFetching is true during any fetch, including the background refetch . The mutation hook’s isLoading is true while the mutation is running, and the data property holds the server’s response . The query hook’s error is the error object, and the mutation hook’s error is the same. The refetch function on the query hook triggers a new fetch, and the reset function on the mutation hook clears the mutation’s state.
The query hook accepts a second argument with the skip option, which defers the fetch until the condition is true.
const { data } = useGetPostQuery(id, { skip: !id })
The skip: !id option is the dependent-query pattern from the TanStack Query chapter, and it is the mechanism that defers the fetch until the ID is defined . The hook is still called, but it does not fetch until the condition is true.
Complete Example Session
// ============================================
// PART 1: THE TYPES
// ============================================
export interface Post {
id: number
title: string
body: string
}
// ============================================
// PART 2: THE API SLICE
// ============================================
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
tagTypes: ['Post'],
endpoints: (build) => ({
// ============================================
// PART 3: THE QUERY ENDPOINTS
// ============================================
getPosts: build.query<Post[], void>({
query: () => 'posts',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Post' as const, id })), { type: 'Post', id: 'LIST' }]
: [{ type: 'Post', id: 'LIST' }],
}),
getPost: build.query<Post, number>({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
// ============================================
// PART 4: THE MUTATION ENDPOINTS
// ============================================
addPost: build.mutation<Post, Pick<Post, 'title' | 'body'>>({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: build.mutation<Post, { id: number; changes: Partial<Post> }>({
query: ({ id, changes }) => ({ url: `posts/${id}`, method: 'PATCH', body: changes }),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: build.mutation<void, number>({
query: (id) => ({ url: `posts/${id}`, method: 'DELETE' }),
invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
})
export const {
useGetPostsQuery,
useGetPostQuery,
useAddPostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = api
// ============================================
// PART 5: THE COMPONENT
// ============================================
function PostList() {
const { data: posts = [], isLoading } = useGetPostsQuery()
const [deletePost] = useDeletePostMutation()
if (isLoading) return <p>Loading...</p>
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<button onClick={() => deletePost(post.id)}>Delete</button>
</li>
))}
</ul>
)
}
The five parts demonstrate the endpoint definitions: the types, the API slice, the query endpoints, the mutation endpoints, and the component. The critical distinction is between Parts 3 and 4: the queries provide tags, and the mutations invalidate them. The deletePost mutation invalidates the tag for the deleted post, which refetches the list if it provides the list tag and the detail if it provides the post tag.
Quick Reference
Query Endpoint
| Element | Code |
|---|---|
| Definition | build.query<Data, Arg>({ query }) |
| Query function | (arg) => 'url' or (arg) => ({ url, method, body }) |
| Tag provision | providesTags |
| Generated hook | useGetXQuery(arg) |
| Hook return | { data, isLoading, isFetching, isError, error, refetch } |
Mutation Endpoint
| Element | Code |
|---|---|
| Definition | build.mutation<Data, Arg>({ query }) |
| Query function | (arg) => ({ url, method, body }) |
| Tag invalidation | invalidatesTags |
| Generated hook | useXMutation() |
| Hook return | [trigger, { isLoading, isError, data, error, reset }] |
Tag Options
| Option | Form | Use Case |
|---|---|---|
providesTags | Static array | The query tags the whole list |
providesTags | Function | The query tags each item |
invalidatesTags | Static array | The mutation affects the list |
invalidatesTags | Function | The mutation affects a specific item |
Hook Options
| Option | Purpose |
|---|---|
skip | Defer the fetch until the condition is true |
pollingInterval | Refetch on an interval |
refetchOnFocus | Refetch when the window regains focus |
refetchOnMountOrArgChange | Refetch when the component mounts or the argument changes |
Best Practices
✅ Do This:
// Type the data and the argument
build.query<Post[], void>({ ... }) # ✅
// Use providesTags for queries
providesTags: ['Post'] # ✅
// Use invalidatesTags for mutations
invalidatesTags: [{ type: 'Post', id: 'LIST' }] # ✅
// Use the function form when the tag depends on the argument
invalidatesTags: (result, error, id) => [{ type: 'Post', id }] # ✅
// Use skip for dependent queries
useGetPostQuery(id, { skip: !id }) # ✅
// Use isLoading for the initial load and isFetching for any fetch
if (isLoading) return <Spinner /> # ✅
❌ Don’t Do This:
// Omit the generics
build.query({ query: () => 'posts' }) // data is unknown # ❌
// Forget providesTags
// The mutation cannot invalidate the query # ❌
// Use invalidatesTags with a tag the query does not provide
invalidatesTags: ['User'] // No effect # ❌
// Use isLoading for background refetches
if (isLoading) return <Spinner /> // The list flickers # ❌
// Fetch without the skip option when the argument is undefined
useGetPostQuery(undefined) // Request to /posts/undefined # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Data is unknown | No generic | build.query<Post[], void> |
| Mutation does not refresh the query | Tags do not match | Add providesTags and invalidatesTags |
Request to /undefined | Missing skip | { skip: !id } |
| List flickers on refetch | isLoading used instead of isFetching | Use isFetching |
| TypeScript error on tags | Type broadened | Use 'Post' as const |
Real-World Examples
1. Query with no argument
getPosts: build.query<Post[], void>({ query: () => 'posts' })
2. Query with an argument
getPost: build.query<Post, number>({ query: (id) => `posts/${id}` })
3. Query with params
getPostsPaginated: build.query<Post[], { page: number }>({ query: ({ page }) => ({ url: 'posts', params: { page } }) })
4. Mutation with POST
addPost: build.mutation<Post, NewPost>({ query: (body) => ({ url: 'posts', method: 'POST', body }) })
5. Mutation with PATCH
updatePost: build.mutation<Post, { id: number; changes: Partial<Post> }>({ query: ({ id, changes }) => ({ url: `posts/${id}`, method: 'PATCH', body: changes }) })
6. Mutation with DELETE
deletePost: build.mutation<void, number>({ query: (id) => ({ url: `posts/${id}`, method: 'DELETE' }) })
7. Provide tags
providesTags: (result) => result ? [...result.map(({ id }) => ({ type: 'Post' as const, id })), { type: 'Post', id: 'LIST' }] : [{ type: 'Post', id: 'LIST' }]
8. Invalidate tags
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]
9. Skip option
const { data } = useGetPostQuery(id, { skip: !id })
10. Refetch
const { refetch } = useGetPostsQuery()
Visual
Query vs Mutation Hooks
┌──────────────────────────────────────────────────────────────┐
│ THE TWO HOOKS HAVE DIFFERENT SHAPES │
│ │
│ Query hook: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const { data, isLoading, isFetching, refetch } = │ │
│ │ useGetPostsQuery() │ │
│ │ │ │
│ │ → object return │ │
│ │ → data is Post[] | undefined │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Mutation hook: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const [addPost, { isLoading, data, reset }] = │ │
│ │ useAddPostMutation() │ │
│ │ │ │
│ │ → tuple return │ │
│ │ → addPost is the trigger function │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Tag Invalidation
┌──────────────────────────────────────────────────────────────┐
│ THE QUERY PROVIDES; THE MUTATION INVALIDATES │
│ │
│ getPosts query: │
│ └── providesTags: ['Post'] │
│ │
│ addPost mutation: │
│ └── invalidatesTags: [{ type: 'Post', id: 'LIST' }] │
│ │
│ When addPost succeeds: │
│ → the 'Post' tag is invalidated │
│ → the getPosts query is refetched │
│ → the component re-renders with the new data │
└──────────────────────────────────────────────────────────────┘
isLoading vs isFetching
┌──────────────────────────────────────────────────────────────┐
│ isLoading IS TRUE ONLY ON THE INITIAL LOAD │
│ │
│ isLoading: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ true → no data yet │ │
│ │ false → data is present or the query is not running │ │
│ │ → use for the initial spinner │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ isFetching: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ true → any fetch, including background refetch │ │
│ │ → use for the subtle refresh indicator │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Query definition | build.query<Data, Arg>({ query }) |
| Mutation definition | build.mutation<Data, Arg>({ query }) |
| Query tag | providesTags |
| Mutation tag | invalidatesTags |
| Query hook | useGetXQuery(arg) |
| Mutation hook | useXMutation() |
| Query return | { data, isLoading, isFetching, isError, error, refetch } |
| Mutation return | [trigger, { isLoading, isError, data, error, reset }] |
| Deferral option | { skip: condition } |
| Initial-load flag | isLoading |
| Any-fetch flag | isFetching |
Key takeaways:
- A query endpoint is for reading, and a mutation endpoint is for writing. The
build.querymethod defines a read operation with aqueryfunction that returns a URL or a request object. Thebuild.mutationmethod defines a write operation with aqueryfunction that returns the request object . - The generics type the data and the argument. The
build.query<Post[], void>form tells TypeScript that the query returns an array of posts and takes no argument. Thebuild.mutation<Post, NewPost>form tells it that the mutation returns a post and takes a new-post object . - The
providesTagsandinvalidatesTagsoptions connect the reads and the writes. The query provides the tags, and the mutation invalidates them. When the mutation succeeds, RTK Query refetches the queries that provide the invalidated tags . - The query hook and the mutation hook have different shapes. The query hook returns an object with the data and the flags, and the mutation hook returns a tuple with the trigger function and the result object. The component must use the correct shape for each .
isLoadingis for the initial load, andisFetchingis for any fetch. TheisLoadingflag istrueonly when there is no data, and theisFetchingflag istrueduring any fetch, including background refetches. The component should useisLoadingfor the initial spinner andisFetchingfor the subtle refresh indicator .- The
skipoption defers the fetch. TheuseGetPostQuery(id, { skip: !id })form prevents the request until the ID is defined. It is the dependent-query pattern for RTK Query, and it is the correct choice when the argument is not yet available .
Remember: An endpoint is the definition of a data operation. The build.query defines a read, and the build.mutation defines a write. The generics type the data and the argument, the providesTags and invalidatesTags options connect the reads and the writes, and the generated hooks expose the data and the flags. The query hook returns an object, and the mutation hook returns a tuple. The isLoading flag is for the initial load, and the isFetching flag is for any fetch. The skip option defers the fetch. The result is a data layer that handles the caching, the loading states, and the invalidation from a single endpoint definition.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!