A React Query structure where one invalidation refreshes everything that changed
Small admin apps become messy fast: URLs typed everywhere, axios calls inside components, and stale screens after a save. Here is the structure I used for the auth admin panel: single sources of truth for endpoints, routes, query keys and config; hierarchical query keys so one invalidation refreshes a whole subtree; and one mutation wrapper for every write.
The problem
In a data-heavy admin panel, scattered URLs, axios calls inside components and hand-picked cache invalidation led to stale screens and inconsistent error handling.
The result
Every URL, key and setting has one home, every write behaves the same way, and one invalidation refreshes everything a change affects.
Stack
The problem
The auth admin panel is a classic data-heavy internal app: applications, their permission catalogs, groups, permissions per (group, app), members, users, service tokens. Almost every screen reads several of those, and almost every action changes several of them.
Apps like this tend to decay in the same ways:
- URLs typed by hand in many files:
/applications/${id}/groups, slightly different in each place, until one is wrong. - axios calls inside components, each with its own loading and error handling.
- Stale screens after a save: you activate a group for an app, go back to the app page, and the group count hasn't changed until you refresh.
- Inconsistent feedback: some actions show a toast, some don't, some show the backend's error, some show "Something went wrong".
The fix wasn't a library. It was a few strict rules about where things live.
The details
The rule: one source of truth, one job per file
src/
lib/
config.ts the only file that reads process.env
routes.ts every frontend path
api/
endpoints.ts every backend path
client.ts the one axios instance
errors.ts apiError() + the HTTP statuses this API uses
query-keys.ts every React Query key
use-api-mutation.ts the one mutation wrapper
features/<feature>/
api.ts the only place axios is called
queries.ts the only place components get data
components/ the screens
app/ routes only: every page is a few lines
And the rules that keep it that way:
- No URL is written by hand. Backend paths come from
endpoints.ts; frontend paths fromroutes.ts. - Components never import axios. They call hooks from
features/*/queries.ts. - Every write goes through
useApiMutation. - Query keys are hierarchical.
- Nothing reads
process.envexceptconfig.ts.
The challenges
- Invalidation is the hard part of client caching. After a write, which cached queries are now wrong? Listing them by hand in every mutation is fragile.
- Real APIs aren't perfectly consistent. Responses come back as
{ message, data },{ data, total, page, limit },{ authenticated, user }or{ ok }. - Some errors should be toasts, some inline. A failed field validation belongs under the field; a failed delete belongs in a toast.
- Configuration mistakes should fail loudly. A panel pointing at the wrong API is worse than a panel that doesn't start.
The solution
1. Every backend path in one file
export const endpoints = {
applications: {
root: '/applications',
byId: (appId: number) => `/applications/${appId}`,
syncCatalog: (appId: number) => `/applications/${appId}/sync-catalog`,
restore: (appId: number) => `/applications/${appId}/restore`,
hardDelete: (appId: number) => `/applications/${appId}/hard`,
},
groups: {
root: '/groups',
byId: (groupId: number) => `/groups/${groupId}`,
/** One activation: POST creates it, DELETE revokes it. */
app: (groupId: number, appId: number) => `/groups/${groupId}/apps/${appId}`,
appPermissions: (groupId: number, appId: number) => `/groups/${groupId}/apps/${appId}/permissions`,
},
// …
};
Functions for parameterized paths mean a typo is a TypeScript error, not a 404 in production. The file also became the best documentation of the API's shape, with comments explaining choices like "groups are global, so they're no longer nested under an application".
The same idea applies to frontend paths in routes.ts, so every <Link> uses routes.appGroup(appId, groupId) instead of a hand-built string.
2. Hierarchical query keys
This is the heart of the structure:
export const queryKeys = {
applications: ['applications'] as const,
applicationList: (status: Status) => ['applications', 'list', status] as const,
application: (appId: number) => ['applications', appId] as const,
catalog: (appId: number) => ['applications', appId, 'catalog'] as const,
serviceTokens: (appId: number) => ['applications', appId, 'service-tokens'] as const,
groups: ['groups'] as const,
group: (groupId: number) => ['groups', groupId] as const,
groupAppPermissions: (groupId: number, appId: number) =>
['groups', groupId, 'apps', appId, 'permissions'] as const,
groupMembers: (groupId: number) => ['groups', groupId, 'members'] as const,
};
TanStack Query matches keys by prefix. Invalidating ['applications', 1] also invalidates every key that starts with it:
['applications', 1] ✔ invalidated
['applications', 1, 'catalog'] ✔ invalidated
['applications', 1, 'service-tokens'] ✔ invalidated
['applications', 2, 'catalog'] ✘ untouched
['groups', 4] ✘ untouched
So the shape of the keys mirrors the shape of the data, and invalidation becomes a statement about what changed, not a list of every screen that might show it:
- syncing app 1's catalog → invalidate
application(1)→ its catalog, its counts and its tokens all refetch, - disabling an app → invalidate
applications→ every list tab and every app detail refetches, - editing a group → invalidate
group(4)→ its members and its per-app permissions refetch.
The keys also encode real design decisions, and the comments say so:
- "Groups are global, so their keys start at the root, not under an app."
- "Tokens live under their application, so disabling an app clears them too."
- "The one key that is per (group, app): permissions differ per application."
3. api.ts: the only place axios is called
Each feature has a tiny API file that knows the endpoint and the exact response shape of each call:
export async function fetchApplication(appId: number): Promise<Application> {
const res = await api.get<ApiEnvelope<Application>>(endpoints.applications.byId(appId));
return res.data.data;
}
export async function fetchUsers(query: UsersQuery): Promise<Paginated<User>> {
const res = await api.get<Paginated<User>>(endpoints.users.root, { params: query });
return res.data; // { data, total, page, limit }: different envelope
}
There's deliberately no generic unwrap(). The API's envelopes aren't consistent, and a "smart" helper that guesses where the data is would hide those differences until one day it guesses wrong. Typing each endpoint on its own is a few extra lines and zero surprises.
4. queries.ts: the only place components get data
export function useApplication(appId: number) {
return useQuery({
queryKey: queryKeys.application(appId),
queryFn: () => fetchApplication(appId),
enabled: Number.isFinite(appId),
});
}
Components only ever see hooks. They don't know about axios, endpoints or keys. Changing how something is fetched touches one file.
Defaults are chosen to make the common case automatic. For example, useApplications() defaults to status = 'active', so every picker, the sidebar and the command palette ignore disabled apps without anyone remembering to filter them.
5. One mutation wrapper for every write
export function useApiMutation<TData, TVariables = void>({
mutationFn,
successMessage,
invalidates,
errorToast = true,
onSuccess,
onError,
}: ApiMutationOptions<TData, TVariables>) {
const queryClient = useQueryClient();
return useMutation<TData, unknown, TVariables>({
mutationFn,
onSuccess: (data, variables) => {
for (const queryKey of invalidates?.(data, variables) ?? []) {
void queryClient.invalidateQueries({ queryKey });
}
if (successMessage) {
toast.success(typeof successMessage === 'function' ? successMessage(data, variables) : successMessage);
}
onSuccess?.(data, variables);
},
onError: (error, variables) => {
if (errorToast) toast.error(apiError(error)); // the backend's own message
onError?.(error, variables);
},
});
}
Every write gets the same three things for free: a success toast, cache invalidation and the backend's real error message. A feature mutation becomes a short declaration:
export function useSyncCatalog(appId: number) {
return useApiMutation({
mutationFn: () => syncCatalog(appId),
successMessage: (entries) => `Synced ${entries.length} permissions.`,
invalidates: () => [queryKeys.application(appId)],
errorToast: false, // the screen shows an inline error card (the app may be down: 424)
});
}
The options exist for the real exceptions:
errorToast: falsewhen the screen shows the error inline (a form field, an error card),- no
successMessagewhen something better follows, like the reveal-once API key dialog after registering an app.
6. Fail fast on configuration
function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value.replace(/\/$/, '');
}
config.ts is the only file that reads process.env, and it normalizes values (like trailing slashes) once. A missing or wrong API URL is caught at startup, not as a confusing failure on the first click.
7. Pages are just routes
With everything else in features, each app/ page is a few lines that render a screen component. Routing and product logic never mix, and moving a screen is trivial.
Lessons learned
- Give every kind of value one home. Endpoints, routes, keys and config each live in exactly one file.
- Mirror your data in your query keys. Hierarchical keys turn invalidation into "this thing changed" instead of "refresh these ten screens".
- Encode design decisions in key shapes. Global groups at the root, tokens under their app: the cache follows the model.
- Keep axios out of components. Feature
api.ts+queries.tsfiles make data access predictable and replaceable. - Don't abstract away inconsistency. A generic unwrap over inconsistent envelopes hides bugs; explicit types reveal them.
- One wrapper for all writes. Consistent toasts, invalidation and errors, with explicit opt-outs for real exceptions.
- Choose defaults that make the common case automatic. "Active only" by default means nobody forgets to filter.
- Fail fast on config. A panel that refuses to start is better than one pointing at the wrong API.
FAQ
How do hierarchical query keys work in TanStack Query?
Query keys are arrays, and invalidateQueries matches by prefix by default. If a key starts with ['applications', 1], invalidating that prefix refreshes it too. Designing keys as a tree lets one invalidation refresh everything under a resource.
Should React components call axios directly?
It's better not to. Put requests in small API modules and expose data through hooks. Components stay simple, and fetching, caching and error handling stay consistent and easy to change.
Is a generic API response unwrap helper a good idea?
Only if every endpoint really uses the same envelope. If responses differ (paginated, wrapped, plain), typing each endpoint explicitly avoids silent bugs where the helper picks the wrong field.
How do I show consistent success and error messages for mutations?
Wrap useMutation once with your toast, invalidation and error-message logic, and use that wrapper for every write. Allow explicit options for cases that need inline errors or no toast.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk