TL;DR
Campus dropdown changes, data doesn’t. Why? React Query cache doesn’t know about tenant IDs. Add tenantId to query keys. Problem solved.
The Problem: Switching Tenants, Seeing Old Data
Building a multi-campus school management system. User switches from Campus A to Campus B using a dropdown. Expected: Campus B data. Reality: Still showing Campus A data.
User: Switches dropdown from "Seoul" to "Busan"
Screen: Still shows Seoul data 😱
User: Hits F5
Screen: Finally shows Busan data ✅
Using React Query, everything should be reactive, right? Checked the network tab - no API call was being made. That’s when it hit me.
Root Cause: React Query Caches by Query Key
React Query uses query keys to identify cached data. Same key = same cache. Different key = different cache. Simple, but easy to miss in multi-tenant apps.
The Problematic Code
// ❌ Query key doesn't change when campus changes
export const useTrashTreeStructure = (type?: string) => {
return useQuery({
queryKey: ['trash', 'tree', { type }], // No campusId!
queryFn: () => trashApi.getTrashTreeStructure(type),
});
};
When campus changes from 1 to 2, the query key stays ['trash', 'tree', { type }]. React Query thinks: “Same query, here’s your cached data!”
Meanwhile, the Backend…
The backend identifies campus from cookies:
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if ("selectedCampusId".equals(cookie.getName())) {
campusId = cookie.getValue();
}
}
Cookies update, backend filters correctly, but frontend cache doesn’t know anything changed.
The Fix: Include Tenant ID in Query Keys
Step 1: Add campusId to Query Key
// ✅ Query key changes when campus changes
import { getCampusCookie } from '@/utils/cookies';
export const useTrashTreeStructure = (type?: string) => {
const campusId = getCampusCookie();
return useQuery({
queryKey: ['trash', 'tree', { type, campusId }], // Added campusId!
queryFn: () => trashApi.getTrashTreeStructure(type),
});
};
Now when campus changes:
- Cookie updates:
setCampusCookie(2) - Component re-renders
- Query key changes:
{ campusId: 1 }→{ campusId: 2 } - React Query: “New key! Fetching fresh data!”
Step 2: Make It Explicit with Headers
Cookies are sent automatically, but they’re invisible in dev tools. Added custom headers for clarity:
// axios interceptor
axiosInstance.interceptors.request.use(
async (config) => {
const campusId = getCampusCookie();
if (campusId !== null) {
config.headers['X-Campus-Id'] = campusId.toString();
}
return config;
}
);
Backend updated to check headers first:
// Priority 1: Header
String campusIdParam = request.getHeader("X-Campus-Id");
// Priority 2: Cookie (backward compatibility)
if (campusIdParam == null) {
// Read from cookie
}
Now I can see X-Campus-Id: 2 in the network tab. Debugging became 10x easier.
Apply Everywhere
This isn’t just about one API. Every campus-filtered endpoint needs this:
// Student details
export const useStudentDetail = (studentId: number) => {
const campusId = getCampusCookie();
return useQuery({
queryKey: ['students', studentId, { campusId }], // Here too
queryFn: () => studentApi.getStudentDetail(studentId),
});
};
// Task templates
export const useTaskTemplates = () => {
const campusId = getCampusCookie();
return useQuery({
queryKey: ['task-templates', { campusId }], // And here
queryFn: () => taskApi.getTaskTemplates(),
});
};
The pattern: Every query key needs the tenant ID.
The Complete Flow
[User changes campus dropdown]
↓
setCampusCookie(2) // Update cookie
↓
Component re-renders
↓
getCampusCookie() → 2 // Read new value
↓
Query key: { campusId: 1 } → { campusId: 2 }
↓
React Query: "New query! Fetching..."
↓
axios: Adds X-Campus-Id: 2 header
↓
Server: Returns campus 2 data only
↓
UI updates instantly ✨
Results
Before
- Change campus → Need F5
- User: “Is this a bug?”
- Dev: “Just refresh…”
After
- Change campus → Instant update
- Separate cache per campus (better performance)
- Visible headers in network tab
Lessons Learned
1. Query Keys Must Include All Dependencies
// ❌ Bad - Ambiguous
queryKey: ['students'] // Which campus? What filters?
// ✅ Good - Explicit
queryKey: ['students', { campusId, grade, status }]
2. Design for Multi-Tenancy from Day One
Retrofitting is painful. Every hook needs updating. Start with:
- Tenant ID in query keys
- Axios interceptors for headers
- Backend auto-filtering
3. Cookies vs Headers
| Cookies | Headers |
|---|---|
| Sent automatically | Explicit |
| Hard to debug | Visible in network tab |
| CORS complexity | Need CORS config |
Use both. Headers for clarity, cookies for backup.
CORS Configuration
Custom headers need CORS allowlist:
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Campus-Id" // Don't forget this!
));
Without this, preflight requests will fail.
Performance Bonus
With campus-specific query keys, each campus gets its own cache:
Campus 1: ['students', { campusId: 1 }] → 500 students cached
Campus 2: ['students', { campusId: 2 }] → 300 students cached
Switching between campuses is now instant if data is already cached. No unnecessary refetches.
Future Improvements
Currently reading cookies in every hook. Context would be cleaner:
// Future approach
const { currentCampusId } = useCampus(); // From Context
queryKey: ['students', currentCampusId]
One source of truth, easier to maintain.
Common Pitfalls
1. Forgetting Some Hooks
One missed hook = one confused user. Search your codebase:
grep -r "useQuery" src/ | grep -v "campusId"
2. Not Invalidating on Logout
When user switches accounts, invalidate all queries:
queryClient.invalidateQueries(); // Clear everything
3. Forgetting Background Refetches
React Query refetches on window focus. Make sure query keys are correct or users see wrong tenant data after alt-tabbing.
Key Takeaway: In multi-tenant React Query apps, tenant ID belongs in every query key. Miss it once, spend hours debugging why data doesn’t update. Learn from my mistake. 🤦♂️
Comments