export interface Post {
id: string;
body: string;
liked: boolean;
likeCount: number;
}
export const postKey = (id: string) => ['post', id] as const;
export function toggleLike(post: Post): Post {
const liked = !post.liked;
return {
...post,
liked,
likeCount: post.likeCount + (liked ? 1 : -1),
};
}
export async function sendLike(id: string, liked: boolean): Promise<Post> {
const res = await fetch(`/api/posts/${id}/like`, {
method: liked ? 'POST' : 'DELETE',
});
if (!res.ok) throw new Error(`Failed to update like: ${res.status}`);
return res.json();
}
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Post, postKey, toggleLike, sendLike } from './post';
interface Context {
previous?: Post;
}
export function useLikePost(id: string) {
const qc = useQueryClient();
return useMutation<Post, Error, void, Context>({
mutationFn: async () => {
const current = qc.getQueryData<Post>(postKey(id));
const nextLiked = !current?.liked;
return sendLike(id, nextLiked);
},
onMutate: async () => {
await qc.cancelQueries({ queryKey: postKey(id) });
const previous = qc.getQueryData<Post>(postKey(id));
if (previous) {
qc.setQueryData<Post>(postKey(id), toggleLike(previous));
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
qc.setQueryData<Post>(postKey(id), context.previous);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: postKey(id) });
},
});
}
import { useQuery } from '@tanstack/react-query';
import { Post, postKey } from './post';
import { useLikePost } from './useLikePost';
export function LikeButton({ id }: { id: string }) {
const { data: post } = useQuery<Post>({ queryKey: postKey(id) });
const like = useLikePost(id);
if (!post) return null;
return (
<button
type="button"
aria-pressed={post.liked}
disabled={like.isPending}
onClick={() => like.mutate()}
className={post.liked ? 'like liked' : 'like'}
>
<span aria-hidden>{post.liked ? '♥' : '♡'}</span>
<span>{post.likeCount}</span>
</button>
);
}
This snippet demonstrates the optimistic-update pattern for a like/unlike toggle built on React Query (@tanstack/react-query). The core idea is that user interactions like tapping a heart should feel instant: rather than waiting for the server round-trip before updating the UI, the cache is patched immediately and only reconciled with the server afterward. If the request fails, the previous cache state is restored so the UI never lies for long.
The useLikePost hook tab wires this up through the four useMutation lifecycle callbacks. In onMutate, the hook first calls cancelQueries to abort any in-flight refetches that could overwrite the optimistic write with stale data — a subtle but important step, since a background fetch resolving after the mutation would clobber the local edit. It then snapshots the current cache with getQueryData and applies the toggle with setQueryData, computing the new liked flag and adjusting likeCount accordingly. That snapshot is returned as the mutation context.
If the network call rejects, onError receives that same context and rolls the cache back via setQueryData, guaranteeing the UI matches reality again. Regardless of success or failure, onSettled calls invalidateQueries so the post is eventually refetched and any drift (for example, a like count changed by another user) is corrected. This combination of optimistic write plus eventual invalidation is the canonical trade-off: maximum perceived speed with a self-healing fallback.
The PostQuery types tab defines the Post shape and a small updater helper so the cache math stays pure and testable. The LikeButton tab consumes the hook and shows how little the component needs to know — it renders from cache and calls mutate, letting isPending drive a disabled state to prevent double-taps. A key pitfall the code guards against is race conditions between rapid clicks; because each onMutate re-reads the freshest cache snapshot, sequential toggles compose correctly. Developers reach for this pattern whenever latency would otherwise make a common action feel sluggish, such as likes, follows, or bookmarks.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import SwiftUI
struct ContentView: View {
@State private var username = ""
@State private var isLoggedIn = false
@StateObject private var viewModel = LoginViewModel()
SwiftUI declarative UI with state management
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
Share this code
Here's the card — post it anywhere.