This lesson traces all five CRUD operations for posts end-to-end — from the React component through the RTK Query layer to the FastAPI endpoint and back. Each operation demonstrates a different aspect of the integration: cache reads on list/get, cache invalidation on create/update/delete, optimistic updates for immediate feedback, and navigation after successful mutations. Reading the Network tab and Redux DevTools while these operations run will build an intuitive understanding of how the full-stack data flow works in practice.
1. List Posts — Paginated GET
// Component subscribes to cache; RTK Query manages the fetch lifecycle
function HomePage() {
const [page, setPage] = useState(1);
const [tag, setTag] = useState(null);
const { data, isLoading, isFetching } = useGetPostsQuery({ page, tag });
// What happens:
// 1. Component mounts → RTK Query checks cache for key "getPosts({page:1, tag:null})"
// 2. Cache miss → dispatches getPosts/pending → HTTP GET /api/posts?page=1&page_size=10
// 3. FastAPI returns { items: [...], total: 42, pages: 5 }
// 4. RTK Query dispatches getPosts/fulfilled → stores in cache
// 5. Component re-renders with data.items
// 6. User clicks page 2 → new cache key → repeat for page 2
// 7. User clicks back to page 1 → cache HIT → no HTTP request, instant render
return (
<div>
<PostList posts={data?.items ?? []} isLoading={isLoading} />
{isFetching && !isLoading && (
<span className="text-xs text-gray-400">Refreshing...</span>
)}
<Pagination page={page} totalPages={data?.pages ?? 1} onPageChange={setPage} />
</div>
);
}
Note:
isFetching is true whenever RTK Query is making a network request for this query — including background refetches triggered by cache invalidation. isLoading is true only for the initial load (cache is empty). Distinguishing between them lets you show a full skeleton on initial load but just a subtle “refreshing” indicator on background refetches, which provides better UX than showing a skeleton every time a post is liked or deleted.Tip: Set
refetchOnMountOrArgChange: true on a query to always refetch when the component mounts, even if there is cached data. Useful for pages where staleness is unacceptable (a user’s dashboard where they expect to see their latest posts immediately). For the home page feed, the default behaviour (use cached data, refetch only after cache invalidation) is better for performance.Warning: RTK Query’s default cache lifetime is 60 seconds (
keepUnusedDataFor: 60). When all components subscribing to a cache entry unmount, the data is kept for 60 seconds and then garbage collected. If the user navigates away and back within 60 seconds, they see the cached data immediately. After 60 seconds, a fresh fetch is triggered. Adjust keepUnusedDataFor per endpoint based on how quickly the data changes — posts can be cached longer (5 minutes) than live notification counts (10 seconds).2. View Post — GET by ID with Cache Hit
function PostDetailPage() {
const { postId } = useParams();
const { data: post, isLoading } = useGetPostByIdQuery(Number(postId));
// Cache hit scenario:
// - User already saw this post in the list (list cache has { items: [{id: 42, title: ...}] })
// - But the list only has partial post data (no body, no comments count)
// - getPostById is a SEPARATE cache entry from getPosts
// - So PostDetailPage ALWAYS makes a fresh request for the full post data
//
// Optimisation: pass initial data from the list cache:
// const listData = useSelector(selectFromPostsList(postId));
// initialData is shown instantly while the full fetch completes
if (isLoading) return <PostDetailSkeleton />;
if (!post) return <NotFoundPage />;
return <PostDetail post={post} />;
}
3. Create Post — Mutation + Cache Invalidation
function PostEditorPage() {
const navigate = useNavigate();
const toast = useToast();
const [createPost, { isLoading }] = useCreatePostMutation();
async function handleSubmit(formValues) {
try {
const newPost = await createPost(formValues).unwrap();
// ① createPost dispatches POST /api/posts
// ② FastAPI creates the post, returns the full post object
// ③ RTK Query invalidates { type: "Post", id: "LIST" }
// ④ Any component using useGetPostsQuery automatically refetches
// (the home page feed updates in the background!)
// ⑤ navigate takes the user to the new post
toast.success("Post published!");
navigate(`/posts/${newPost.id}`, { replace: true });
} catch (error) {
toast.error(normaliseApiError(error, "Failed to publish post"));
}
}
// ...
}
4. Update Post — Optimistic Update
function LikeButton({ postId }) {
const [likePost, { isLoading }] = useLikePostMutation();
const { data: post } = useGetPostByIdQuery(postId);
const toast = useToast();
async function handleLike() {
// Optimistic update: update the cache immediately before the API call
// This is done with RTK Query's onQueryStarted:
try {
await likePost(postId).unwrap();
// Cache for getPostById({postId}) is invalidated → fresh data fetched
} catch {
toast.error("Could not update like — please try again");
}
}
return (
<button onClick={handleLike} disabled={isLoading}>
♥ {post?.like_count ?? 0}
</button>
);
}
5. Delete Post — DELETE + Navigation + Invalidation
function DeletePostButton({ postId }) {
const navigate = useNavigate();
const toast = useToast();
const [deletePost, { isLoading }] = useDeletePostMutation();
async function handleDelete() {
if (!window.confirm("Delete this post? This cannot be undone.")) return;
try {
await deletePost(postId).unwrap();
// ① DELETE /api/posts/:id → 204 No Content
// ② Invalidates Post:postId and Post:LIST
// ③ Any components showing this post's data will refetch/hide it
toast.success("Post deleted");
navigate("/dashboard", { replace: true });
} catch (error) {
toast.error(normaliseApiError(error, "Failed to delete post"));
}
}
return (
<button onClick={handleDelete} disabled={isLoading}
className="text-red-600 hover:text-red-700">
{isLoading ? "Deleting..." : "Delete Post"}
</button>
);
}
End-to-End Data Flow Diagram
React Component
↓ calls hook
RTK Query Hook (useGetPostsQuery / useCreatePostMutation)
↓ reads from / writes to
RTK Query Cache (Redux store: state.blogApi)
↕ HTTP requests
fetchBaseQuery (with auth token in headers)
↕
Vite Proxy (/api → localhost:8000) [DEV]
OR
Nginx Reverse Proxy (/api → FastAPI) [PROD]
↕
FastAPI Endpoint (e.g., GET /api/posts)
↕
SQLAlchemy + PostgreSQL
Common Mistakes
Mistake 1 — Not calling .unwrap() on mutations (errors silently ignored)
❌ Wrong — error not caught:
await createPost(data); // returns {data, error} — error not thrown!
✅ Correct:
await createPost(data).unwrap(); // ✓ throws on error → goes to catch block
Mistake 2 — Using navigate before the mutation completes
❌ Wrong — navigation before API success:
createPost(data); // fire and forget!
navigate("/dashboard"); // user navigates before post is created
✅ Correct — await the mutation first.