Using TanStack Query
TanStack Query (formerly React Query) manages server state in the browser: caching, request deduplication, background refetching, retries, and cache invalidation. Rails is excellent at server-side data; TanStack Query gives the React side a disciplined way to consume that data without hand-rolling loading flags, retry logic, and ad-hoc caches.
It is the recommended client-side server-state layer for Rails-backed React apps. Rails keeps owning data, routes, auth, sessions, CSRF, validations, mailers, and jobs. TanStack Query does not replace any of that; it consumes the JSON Rails already knows how to produce.
Support Model
- Client-side TanStack Query works in open-source React on Rails with no special integration. It is a standard React library; register a component that mounts
QueryClientProviderand you are done. - First-paint data seeding (rendering useful rows in the server HTML, then letting the client reuse them) needs nothing more than React on Rails passing the first screen's data as props. The client adopts it through
useQuery({ initialData }). This works in OSS and Pro alike. - Full TanStack Router SSR (server-rendering the route tree and hydrating it) requires React on Rails Pro. See Using TanStack Router. TanStack Query composes with that boundary but does not depend on it.
This guide mirrors the official React on Rails + TanStack starter (live demo). Every snippet below is drawn from that app.
Install
pnpm add @tanstack/react-query
Rails Stays the Source of Truth
A normal Rails controller returns explicit JSON, never raw Active Record:
# app/controllers/api/projects_controller.rb
module Api
class ProjectsController < BaseController
def index
result = ProjectsQuery.from_params(Current.user.projects, params).result
render json: {
projects: result[:records].map { |project| ProjectSerializer.one(project) },
meta: result[:meta]
}
end
end
end
Filtering, sorting, pagination, and authorization stay in Rails. The React side never reaches past this boundary.
One CSRF-Aware Fetch Helper
Put the same-origin and CSRF handling in exactly one place so every query and mutation goes through it:
// app/javascript/lib/apiFetch.ts
import { getCsrfToken } from './getCsrfToken';
type ApiFetchOptions = RequestInit & { json?: unknown };
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
if (options.json !== undefined) headers.set('Content-Type', 'application/json');
const csrfToken = getCsrfToken(); // reads <meta name="csrf-token">
if (csrfToken) headers.set('X-CSRF-Token', csrfToken);
const response = await fetch(path, {
...options,
headers,
credentials: 'same-origin',
body: options.json === undefined ? options.body : JSON.stringify(options.json),
});
const body = await response.json(); // the starter also handles non-JSON and throws ApiError on !response.ok
return body as T;
}
getCsrfToken() reads the token Rails already renders into the page via csrf_meta_tags. Because requests are same-origin with the CSRF header, Rails session auth keeps working.
Shared QueryClient Defaults
Create the QueryClient from a single factory so SSR and client use identical defaults:
// app/javascript/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
Mount the provider once, and keep the client stable across renders with useMemo (or useState). Creating new QueryClient() inline on every render throws the cache away on each render. See Mistake 5 in the RSC context & state guide for the failure mode.
const queryClient = useMemo(() => createQueryClient(), []);
return (
<QueryClientProvider client={queryClient}>
{children}
{showDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</QueryClientProvider>
);
Reading Server State With Stable Query Keys
Every server-side input belongs in the query key, so the cache entry is unique per filter/sort/page and refetches when any of them change:
const projectsQuery = useQuery({
queryKey: ['projects', status, sort, dir, page],
queryFn: () => {
const params = new URLSearchParams({ sort, dir, page: String(page), per_page: '8' });
if (status) params.set('status', status);
return apiFetch<ProjectsResponse>(`${api.projectsPath}?${params}`);
},
});
The same query key identifies the same data on the server and the client. That is what lets the server-rendered cache and the client cache line up.
First Paint Without Spinners
The common failure mode is rendering useful HTML on the server, then immediately showing a spinner and refetching everything on the client. Avoid it by seeding the first screen's data from Rails.
Rails renders the first page into props (only on the route that shows the table, using the same query object as the JSON API so the seed equals a later refetch):
# app/controllers/dashboard_controller.rb
def show
@dashboard_props = {
api: { projectsPath: api_projects_path },
initialProjects: projects_table_initial_load? ? initial_projects : nil,
# ...
}
end
# Seed only on the initial full-page load of the table route. Seeding on other
# routes can let a pre-mount mutation leave a stale seed the table later adopts
# as fresh (staleTime: 30s). See starter PR #174.
def projects_table_initial_load?
request.path == projects_path
end
<%# app/views/dashboard/show.html.erb %>
<%= react_component("DashboardApp", props: @dashboard_props, prerender: !Rails.env.test?) %>
The client adopts the seed as initialData only when its params match the active query key, so the rows render in the initial HTML with no spinner and TanStack Query owns freshness from there:
const { initialProjects } = useDashboardProps();
const initialData =
initialProjects &&
initialProjects.params.status === status &&
initialProjects.params.sort === sort &&
initialProjects.params.dir === dir &&
initialProjects.params.page === page
? initialProjects.response
: undefined;
const projectsQuery = useQuery({
queryKey: ['projects', status, sort, dir, page],
queryFn: () => apiFetch<ProjectsResponse>(/* ... */),
initialData, // first page seeded from Rails; any other filter/sort/page fetches normally
});
Because the seed is passed as initialData without initialDataUpdatedAt, TanStack Query timestamps it at 0, so the table does one background refetch on mount to confirm freshness — the rows still paint immediately, with no spinner. To treat the seed as fresh for staleTime and skip that first refetch, also pass initialDataUpdatedAt set to the server's render time.
initialDatavsdehydrate/HydrationBoundary. TanStack Query also ships a server-prefetch pattern: build aQueryClient,prefetchQuery,dehydrateit into the HTML, and wrap the client tree inHydrationBoundary. See the TanStack Query SSR guide. The starter uses the simpler props-to-initialDataseed because Rails already renders the first page; reach fordehydrate/HydrationBoundarywhen you need to seed many queries at once. Note that theprefetchQuery/dehydratepath needs an awaitable server-render boundary (the Pro TanStack Router SSR path), whereas the props-to-initialDataseed works in open source.
Mutations and Cache Invalidation
Mutations write through the same apiFetch helper, then invalidate or directly update the affected cache entries so the table and metrics refresh once:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (values: ProjectFormValues) =>
apiFetch<ProjectResponse>(api.projectsPath, { method: 'POST', json: { project: values } }),
onSuccess: ({ project }) => {
queryClient.setQueryData(['project', String(project.id)], { project });
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['metrics'] });
},
});
This is cleaner than threading "reload this section" callbacks through many components: the cache is the single place that knows what is stale.
TanStack Query and React Server Components
With React on Rails Pro's React Server Components, the two split the work:
- RSC fetches data for server-rendered, non-interactive pieces. A server component can query a Rails model directly and stream HTML, with no
/apiround-trip and no serializer for that piece. - TanStack Query owns the interactive "live app" islands: refetching, mutations, optimistic updates, pagination, infinite scroll, background refresh, and cache invalidation.
If you are migrating an existing React Query setup into an RSC app, see Migrating from React Query / TanStack Query.
Why This Fits Rails Apps
- Less frontend state boilerplate. Server state ("current user," "projects," "messages," search results) belongs in the query cache, not in Redux or a hand-rolled global store.
- Better perceived performance. The page ships with useful data already present, then stays fresh on the client. You keep the SEO and first-load benefits of server rendering.
- Gradual modernization. Adopt it one React island or one page at a time. No single-page-app rewrite, no framework swap.
Working Example
The React on Rails + TanStack starter (Rails 8 + React 19 + React on Rails Pro) implements every pattern above. The relevant files, each marked with a REFERENCE PATTERN comment:
- Shared fetch + client:
app/javascript/lib/apiFetch.ts,app/javascript/lib/getCsrfToken.ts,app/javascript/lib/queryClient.ts - Queries, mutations, provider:
app/javascript/src/Dashboard/ror_components/DashboardApp.tsx - Rails JSON API:
app/controllers/api/projects_controller.rb - SSR seed:
app/controllers/dashboard_controller.rb,app/views/dashboard/show.html.erb
Try it live at starter.reactonrails.com.
References
- Using TanStack Router (the Pro SSR boundary this pairs with)
- Migrating from React Query / TanStack Query
- RSC Context & State
- TanStack Query docs
- TanStack Query SSR guide