With CORS configured and environment variables in place, the final step is wiring the RTK Query API service to the actual FastAPI endpoints. This means ensuring the authentication token from Zustand reaches every request, mapping FastAPI’s paginated response shape ({ items, total, pages }) to what the React components expect, and verifying the complete round-trip with real data. This lesson traces every part of the connection and identifies the specific places where things commonly break.
Complete RTK Query + Zustand Auth Integration
// src/store/apiSlice.js — fully wired to FastAPI
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { config } from "@/config";
export const blogApi = createApi({
reducerPath: "blogApi",
baseQuery: fetchBaseQuery({
baseUrl: config.apiBaseUrl, // "/api" in production, "http://localhost:8000/api" in dev
prepareHeaders: (headers, { getState }) => {
// Read token from Redux auth slice (if using Redux for auth)
// OR from Zustand store (if using Zustand for auth)
const token =
getState().auth?.accessToken ?? // Redux auth slice
localStorage.getItem("access_token"); // Zustand persisted token fallback
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ["Post", "Comment", "Tag", "User"],
endpoints: (builder) => ({
// GET /api/posts?page=1&page_size=10&tag=python
// FastAPI returns: { items: [...], total: 42, pages: 5, page: 1, page_size: 10 }
getPosts: builder.query({
query: ({ page = 1, pageSize = 10, tag, search } = {}) => ({
url: "/posts",
params: {
page,
page_size: pageSize,
...(tag ? { tag } : {}),
...(search ? { search } : {}),
},
}),
// Transform: keep FastAPI's shape (components read data.items, data.total)
// No transform needed if components handle it — or normalise here:
transformResponse: (response) => ({
items: response.items,
total: response.total,
pages: response.pages,
page: response.page,
pageSize: response.page_size,
}),
providesTags: (result) =>
result
? [
...result.items.map(({ id }) => ({ type: "Post", id })),
{ type: "Post", id: "LIST" },
]
: [{ type: "Post", id: "LIST" }],
}),
// GET /api/posts/:id
getPostById: builder.query({
query: (postId) => `/posts/${postId}`,
providesTags: (result, error, postId) =>
[{ type: "Post", id: postId }],
}),
// POST /api/posts
createPost: builder.mutation({
query: (postData) => ({
url: "/posts",
method: "POST",
body: postData,
}),
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
// PATCH /api/posts/:id
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: "PATCH",
body: patch,
}),
invalidatesTags: (result, error, { id }) =>
[{ type: "Post", id }, { type: "Post", id: "LIST" }],
}),
// DELETE /api/posts/:id
deletePost: builder.mutation({
query: (postId) => ({ url: `/posts/${postId}`, method: "DELETE" }),
invalidatesTags: (result, error, postId) =>
[{ type: "Post", id: postId }, { type: "Post", id: "LIST" }],
}),
// POST /api/posts/:id/like
likePost: builder.mutation({
query: (postId) => ({ url: `/posts/${postId}/like`, method: "POST" }),
invalidatesTags: (result, error, postId) =>
[{ type: "Post", id: postId }],
}),
// GET /api/users/me
getCurrentUser: builder.query({
query: () => "/users/me",
providesTags: [{ type: "User", id: "ME" }],
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
useLikePostMutation,
useGetCurrentUserQuery,
} = blogApi;
transformResponse callback runs after every successful response and lets you reshape the data before it enters the RTK Query cache. Use it to rename fields (FastAPI’s page_size → pageSize), flatten nested structures, or compute derived values. The transformed data is what components receive from useGetPostsQuery — this is the right place to normalise FastAPI’s snake_case fields to camelCase if you want consistency in your React components.useGetPostsQuery({ page: 1 }), only one HTTP request is made — both components share the same cache entry. When the cache is populated, both components update simultaneously. This is the key advantage over custom hooks from Chapter 41, where each hook call made its own independent request.persist middleware, the access token is written to localStorage under a namespaced key (e.g., auth.state.accessToken inside the JSON object). A plain localStorage.getItem("access_token") will return null if Zustand is persisting under a different key. Either read from the Zustand store directly via useAuthStore.getState().accessToken (outside React components) or standardise on one storage approach.Verifying the Connection
Checklist for verifying React → FastAPI connection:
1. Network tab (DevTools):
✓ Requests go to /api/posts (not localhost:8000 directly)
✓ Response status is 200 (not 401, 403, 404)
✓ Authorization header is present on authenticated requests
✓ Response body matches FastAPI's schema
2. Console (DevTools):
✓ No CORS errors ("Access-Control-Allow-Origin" missing)
✓ No 422 Unprocessable Entity (check request body shape)
✓ No "Failed to fetch" (check Vite proxy / CORS config)
3. Redux DevTools:
✓ blogApi/getPosts/pending then fulfilled dispatched
✓ Cache populated in blogApi state tree
✓ Components reading from cache (no second request on revisit)
4. React DevTools:
✓ Component shows correct data from useGetPostsQuery
✓ isLoading is false when data is available
✓ isFetching briefly true during background refetch
Common Mistakes
Mistake 1 — Token not attached (all requests return 401)
❌ Wrong — prepareHeaders reads from wrong store location:
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token; // field is "accessToken", not "token"!
}
✅ Correct — verify the exact field name in your auth slice or Zustand store.
Mistake 2 — Wrong response shape breaks providesTags
❌ Wrong — assuming response has items array when it does not:
providesTags: (result) =>
result.items.map(({ id }) => ...) // TypeError if result is the array directly!
✅ Correct — check what FastAPI actually returns and guard: result?.items?.map(...).