Recipes
Cross-feature compositions for @mongez/atomic-query — the patterns that come up once you’ve moved past single-fetch boundaries.
Optimistic update with rollback
You’re toggling a like-count on a post. The mutation almost always succeeds, but on failure you want the UI to snap back. Read the current snapshot, apply the optimistic write, mutate, and restore on error.
import { useMutation, queryAtom } from "@mongez/atomic-query";
function useLikePost(postId: string) { return useMutation<Post, void, { previous: Post | undefined }>({ mutationFn: (_vars, { signal }) => api.posts.like(postId, { signal }), onMutate: () => { const previous = queryAtom.getData(["post", postId]) as Post | undefined; queryAtom.updateQueryData<Post>(["post", postId], p => p ? { ...p, likes: p.likes + 1, likedByMe: true } : (p as Post), ); return { previous }; }, onError: (_err, _vars, ctx) => { if (ctx?.previous !== undefined) { queryAtom.updateQueryData<Post>(["post", postId], () => ctx.previous!); } }, onSettled: () => queryAtom.invalidate({ queryKey: ["post", postId] }), });}onSettled invalidates after both success and error, so the server stays the eventual source of truth.
Paginated list + delete-an-item without refetching every page
After deleting an item, splice it out of the cached list instead of round-tripping. For a flat list, removeByIndex (after a findIndex) or updateQueryData with .filter() does the job — strict-equality remove only helps when items compare by reference.
const deleteTodo = useMutation<void, string>({ mutationFn: (id, { signal }) => fetch(`/api/todos/${id}`, { method: "DELETE", signal }).then(() => undefined), onSuccess: (_data, id) => { queryAtom.updateQueryData<Todo[]>(["todos"], old => (old ?? []).filter(t => t.id !== id), ); },});For an insert-at-top pattern, use queryAtom.unshift(["todos"], newTodo). See mongez-atomic-query-list-helpers for the full array-helper surface.
Infinite scroll with IntersectionObserver
A sentinel <div> at the end of the list triggers fetchNextPage when it scrolls into view — and stops firing when there are no more pages.
import { useEffect, useRef } from "react";import { useInfiniteQuery } from "@mongez/atomic-query";
function Feed() { const q = useInfiniteQuery<FeedPage, number>({ queryKey: ["feed"], initialPageParam: 0, queryFn: ({ pageParam, signal }) => fetch(`/api/feed?cursor=${pageParam}`, { signal }).then(r => r.json()), getNextPageParam: last => last.nextCursor ?? undefined, }); const sentinel = useRef<HTMLDivElement>(null);
useEffect(() => { if (!sentinel.current || !q.hasNextPage) return; const io = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting && !q.isFetchingNextPage) q.fetchNextPage(); }); io.observe(sentinel.current); return () => io.disconnect(); }, [q.hasNextPage, q.isFetchingNextPage, q.fetchNextPage]);
return ( <> {q.data?.pages.flatMap(p => p.items).map(item => ( <FeedItem key={item.id} {...item} /> ))} <div ref={sentinel} /> </> );}Prefetch on link hover
By the time the user clicks the link, the destination’s data is already in cache. atomic-query doesn’t ship a prefetchQuery helper — the idiom is: fetch manually, seedQuery to drop the result in the cache, then guard on isStale so repeated hovers don’t refire.
import Link from "next/link";import { queryAtom } from "@mongez/atomic-query";
async function prefetchPost(id: string) { if (!queryAtom.isStale(["post", id], 30_000)) return; const post = await fetch(`/api/posts/${id}`).then(r => r.json()); queryAtom.seedQuery({ queryKey: ["post", id], data: post, freshFor: 30_000 });}
function PostLink({ post }: { post: Post }) { return ( <Link href={`/p/${post.id}`} onMouseEnter={() => prefetchPost(post.id)} onFocus={() => prefetchPost(post.id)}> {post.title} </Link> );}freshFor: 30_000 keeps the seeded data warm long enough that navigation doesn’t immediately re-fetch.
SSR data hand-off via <HydrateQueries>
Fetch in your framework’s loader on the server, emit a JSON payload, hydrate before any useQuery runs on the client. First render is synchronous — no loading flash.
// Server: in a Remix / Next / TanStack Router loaderconst user = await api.users.get(req.params.id);const todos = await api.todos.list({ userId: user.id });return { ssrQueries: [ { queryKey: ["user", user.id], data: user }, { queryKey: ["todos", { userId: user.id }], data: todos }, ],};// Client rootimport { HydrateQueries, type SeedEntry } from "@mongez/atomic-query";
function App({ ssrQueries }: { ssrQueries: SeedEntry[] }) { return ( <HydrateQueries entries={ssrQueries}> <UserPage /> </HydrateQueries> );}queryAtom.useQuery({ queryKey: ["user", id], queryFn }) inside <UserPage /> returns the hydrated data on first render. Subsequent re-fetches honor the configured staleTime / freshFor.
Suspense-driven detail page
The component renders only when data is present — no if (isLoading) return <Spinner /> ladder inside.
import { Suspense } from "react";import { useSuspenseQuery } from "@mongez/atomic-query";
function PostPage({ id }: { id: string }) { const { data: post } = useSuspenseQuery<Post>({ queryKey: ["post", id], queryFn: ({ signal }) => fetch(`/api/posts/${id}`, { signal }).then(r => r.json()), }); return <h1>{post.title}</h1>;}
function PostRoute({ id }: { id: string }) { return ( <ErrorBoundary fallback={<NotFound />}> <Suspense fallback={<Spinner />}> <PostPage id={id} /> </Suspense> </ErrorBoundary> );}Loading state lives in the Suspense boundary; error state lives in the ErrorBoundary. The leaf component reads data as if it’s always there. Note the order: ErrorBoundary must wrap Suspense so it catches throws from the suspended subtree.
Invalidate every query in a segment
After publishing a comment, every cached query whose key starts with ["posts", postId] (the post detail, its comments list, its reactions) should refetch in one call.
queryAtom.invalidate({ queryKey: ["posts", postId] });Segment-aware prefix matching is built in — passing the prefix invalidates every descendant key in one shot. ["posts", 1] matches ["posts", 1, "comments"] and ["posts", 1, "reactions"] but not ["posts", 10]. To invalidate the post itself but leave its comments cached, narrow with exact: true:
queryAtom.invalidate({ queryKey: ["posts", postId], exact: true });Pair with @mongez/http for the fetch layer
atomic-query doesn’t dictate how you make HTTP calls. A common pairing:
import { http } from "@mongez/http";import { queryAtom } from "@mongez/atomic-query";
const queries = { post: (id: string) => ({ queryKey: ["post", id] as const, queryFn: async ({ signal }: { signal: AbortSignal }) => { const { data, error } = await http.get<Post>(`/posts/${id}`, { signal }); if (error) throw error; return data; }, }),};
// At the call siteconst { data } = queryAtom.useQuery(queries.post(postId));The {data, error} shape from @mongez/http maps cleanly: throw on error so atomic-query’s isError / error slots activate; return on success so data populates. Centralising the query definitions (queries.post(id)) keeps queryKey and queryFn paired in one place — and propagating signal into http.get lets atomic-query cancel stale requests.