End-to-End Post CRUD — The Complete Data Flow

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.

🧠 Test Yourself

A user creates a post and is navigated to the new post’s detail page. They press the browser back button to return to the home page. The home page post list was cached before the creation. Does the new post appear?