docs/oss/building-features/tanstack-query.md
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.
QueryClientProvider and you are done.useQuery({ initialData }). This works in OSS and Pro alike.This guide mirrors the official React on Rails + TanStack starter (live demo). Every snippet below is drawn from that app.
pnpm add @tanstack/react-query
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.
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.
Pair this helper with generated Rails response types to avoid
hand-writing ProjectsResponse and similar TanStack Query result interfaces.
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>
);
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.
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 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'] });
},
});
If your app generates Rails response types with
react_on_rails:generate_response_types, use the package's thin Rails action caller so the mutation
response stays tied to the Rails-side contract:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createRailsAction } from 'react-on-rails/railsAction';
import type { RailsResponseType } from '../generated/react_on_rails_response_types';
type ProjectFormValues = {
name: string;
status: string;
};
const createProject = createRailsAction<{ project: ProjectFormValues }, RailsResponseType<'projects.create'>>(
{
path: '/api/projects',
},
);
function useCreateProjectMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProject,
onSuccess: ({ project }) => {
queryClient.setQueryData(['project', String(project.id)], { project });
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['metrics'] });
},
});
}
createRailsAction shares the same-origin, JSON-only, and CSRF assumptions as the starter's apiFetch
pattern, but returns a standalone function that drops directly into useMutation. It does not replace
Rails routes, strong parameters, authorization, or runtime response validation.
Endpoints should return JSON for 2xx responses unless the caller's response type is null; browser
redirects reject like network failures, so Rails auth failures should return JSON 401 or 403
responses for mutation endpoints.
This is cleaner than threading "reload this section" callbacks through many components: the cache is the single place that knows what is stale.
With React on Rails Pro's React Server Components, the two split the work:
/api round-trip and no serializer for that piece.If you are migrating an existing React Query setup into an RSC app, see Migrating from React Query / TanStack Query.
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:
app/javascript/lib/apiFetch.ts, app/javascript/lib/getCsrfToken.ts, app/javascript/lib/queryClient.tsapp/javascript/src/Dashboard/ror_components/DashboardApp.tsxapp/controllers/api/projects_controller.rbapp/controllers/dashboard_controller.rb, app/views/dashboard/show.html.erbTry it live at starter.reactonrails.com.