| | |

Redux Toolkit 14 ⚛️ RTK Query Endpoints — Defining automated queries and mutations

⏱️ Estimated read: 14 min | |

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

ElementCode
Definitionbuild.query<Data, Arg>({ query })
Query function(arg) => 'url' or (arg) => ({ url, method, body })
Tag provisionprovidesTags
Generated hookuseGetXQuery(arg)
Hook return{ data, isLoading, isFetching, isError, error, refetch }

Mutation Endpoint

ElementCode
Definitionbuild.mutation<Data, Arg>({ query })
Query function(arg) => ({ url, method, body })
Tag invalidationinvalidatesTags
Generated hookuseXMutation()
Hook return[trigger, { isLoading, isError, data, error, reset }]

Tag Options

OptionFormUse Case
providesTagsStatic arrayThe query tags the whole list
providesTagsFunctionThe query tags each item
invalidatesTagsStatic arrayThe mutation affects the list
invalidatesTagsFunctionThe mutation affects a specific item

Hook Options

OptionPurpose
skipDefer the fetch until the condition is true
pollingIntervalRefetch on an interval
refetchOnFocusRefetch when the window regains focus
refetchOnMountOrArgChangeRefetch 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

PitfallWhy It HappensFix
Data is unknownNo genericbuild.query<Post[], void>
Mutation does not refresh the queryTags do not matchAdd providesTags and invalidatesTags
Request to /undefinedMissing skip{ skip: !id }
List flickers on refetchisLoading used instead of isFetchingUse isFetching
TypeScript error on tagsType broadenedUse '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

ItemValue
Query definitionbuild.query<Data, Arg>({ query })
Mutation definitionbuild.mutation<Data, Arg>({ query })
Query tagprovidesTags
Mutation taginvalidatesTags
Query hookuseGetXQuery(arg)
Mutation hookuseXMutation()
Query return{ data, isLoading, isFetching, isError, error, refetch }
Mutation return[trigger, { isLoading, isError, data, error, reset }]
Deferral option{ skip: condition }
Initial-load flagisLoading
Any-fetch flagisFetching

Key takeaways:

  • A query endpoint is for reading, and a mutation endpoint is for writing. The build.query method defines a read operation with a query function that returns a URL or a request object. The build.mutation method defines a write operation with a query function 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. The build.mutation<Post, NewPost> form tells it that the mutation returns a post and takes a new-post object .
  • The providesTags and invalidatesTags options 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 .
  • isLoading is for the initial load, and isFetching is for any fetch. The isLoading flag is true only when there is no data, and the isFetching flag is true during any fetch, including background refetches. The component should use isLoading for the initial spinner and isFetching for the subtle refresh indicator .
  • The skip option defers the fetch. The useGetPostQuery(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.


Was this helpful?


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!