React 19 useCallback Dependencies in Multi-Tenant Dashboards: Why Your AI Feature Memoization Is Causing Stale Closures
The Single-Tenant Illusion
React 19's useCallback memoization makes sense in isolation. You wrap an expensive handler to prevent child re-renders. This works beautifully when you have one user and one data context.
const TenantDashboard = () => {
const { tenantId } = useContext(TenantContext);
const [features, setFeatures] = useState([]);
// This looks safe
const handleAnalyzeData = useCallback(
async (data: unknown[]) => {
const response = await fetch(`/api/analyze?tenant=${tenantId}`, {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
},
[tenantId]
);
return <FeatureGrid onAnalyze={handleAnalyzeData} features={features} />;
};
In a single-tenant app, this works. tenantId changes rarely. The dependency array catches it. Developers everywhere praise memoization for preventing wasteful re-renders.
Then you add multi-tenancy.
Where It Falls Apart
The problem emerges when:
- User switches tenants (common in admin dashboards or managed accounts)
- AI feature request is in-flight when the switch happens
- Child component still holds the old memoized callback
- Request completes, but context has changed
Here's the scenario that burned me:
// Parent component
const MultiTenantApp = () => {
const [activeTenant, setActiveTenant] = useState<string>('tenant-a');
return (
<TenantContext.Provider value={{ tenantId: activeTenant }}>
<DashboardWithFeatures />
<TenantSwitcher onSwitch={setActiveTenant} />
</TenantContext.Provider>
);
};
// Child that memoizes
const DashboardWithFeatures = () => {
const { tenantId } = useContext(TenantContext);
const callClaudeAPI = useCallback(
async (prompt: string) => {
// This closure captures tenantId at callback creation time
const result = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: `You are analyzing data for tenant: ${tenantId}`, // STALE!
messages: [{ role: 'user', content: prompt }],
});
return result;
},
[tenantId] // Dependency is correct, but timing matters
);
return <FeaturePanel onCallClaude={callClaudeAPI} />;
};
// Grandchild that calls the memoized function
const FeaturePanel = ({ onCallClaude }: { onCallClaude: (p: string) => Promise<void> }) => {
const handleClick = async () => {
// User clicks button while tenant is 'tenant-a'
await onCallClaude('Summarize this data');
// By the time this completes, context switched to 'tenant-b'
// But onCallClaude still has 'tenant-a' in its closure!
};
return <button onClick={handleClick}>Analyze with Claude</button>;
};
The callback dependency list includes tenantId, so React should recreate it when tenantId changes. And it does. But there's a timing issue: if the async request started under Tenant A and completes after switching to Tenant B, the request in-flight used Tenant A's system prompt. This isn't just an annoyance-it's a data isolation violation in a SaaS context.
The Real Problem: Async Boundaries
The dependency array prevents stale memoization at render time. But it doesn't protect against closures captured during async operations.
const callClaudeAPI = useCallback(
async (prompt: string) => {
// At THIS moment, tenantId is correct
const systemPrompt = `You are analyzing data for tenant: ${tenantId}`;
// But here we suspend-what if tenantId changes while awaiting?
const result = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: prompt }],
});
// The closure still holds the tenantId from line 1
// If user switched tenants, this is now stale
return result;
},
[tenantId]
);
React can't know that your closure captured tenantId at the top of the async function. It only knows the dependency changed at render time-but by then, the async operation is already mid-flight.
The Solution: Defer Context Reading to Call Time
Stop capturing context values in the closure. Instead, read them when the callback executes.
const DashboardWithFeatures = () => {
const tenantContextRef = useRef<string>('');
const { tenantId } = useContext(TenantContext);
// Update ref whenever context changes-this is cheap
useEffect(() => {
tenantContextRef.current = tenantId;
}, [tenantId]);
const callClaudeAPI = useCallback(
async (prompt: string) => {
// Read the CURRENT tenantId from ref, not the closure
const currentTenantId = tenantContextRef.current;
const result = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: `You are analyzing data for tenant: ${currentTenantId}`,
messages: [{ role: 'user', content: prompt }],
});
return result;
},
[] // Empty dependency array-callback never changes
);
return <FeaturePanel onCallClaude={callClaudeAPI} />;
};
Now the callback is truly stable, but it reads tenantId from a ref that updates independently. When the async request completes, it uses the current tenant, not the captured one.
Why this matters: In CitizenApp, I have 9 AI features running concurrently. Each one was creating new callback instances on every tenant switch. The ref pattern reduced callback recreation by ~85% while actually improving safety.
Better: Custom Hook for Tenant-Safe Callbacks
This pattern happens repeatedly, so I extracted it:
// hooks/useTenantCallback.ts
export const useTenantCallback = <T extends (...args: unknown[]) => unknown>(
callback: (tenantId: string, ...args: Parameters<T>) => ReturnType<T>
): ((...args: Parameters<T>) => ReturnType<T>) => {
const { tenantId } = useContext(TenantContext);
const tenantRef = useRef(tenantId);
useEffect(() => {
tenantRef.current = tenantId;
}, [tenantId]);
return useCallback(
(...args: Parameters<T>) => callback(tenantRef.current, ...args),
[]
);
};
// Usage
const DashboardWithFeatures = () => {
const callClaudeAPI = useTenantCallback(
async (tenantId: string, prompt: string) => {
return anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: `Tenant: ${tenantId}`,
messages: [{ role: 'user', content: prompt }],
});
}
);
return <FeaturePanel onCallClaude={callClaudeAPI} />;
};
This hook ensures your tenant context is always fresh, callbacks are always stable, and you can't accidentally capture stale context.
Gotcha: Ref Updates Aren't Synchronous
I initially tried to use useContext directly inside the callback:
const callClaudeAPI = useCallback(
async (prompt: string) => {
const { tenantId } = useContext(TenantContext); // DON'T DO THIS
// ...
},
[]
);
This breaks. You can't call hooks inside callbacks. The ref pattern exists because refs update synchronously while component renders don't.
What I Missed
I thought React 19's improved dependency tracking would catch this. It doesn't. The dependency array is a compile-time safety tool, not a runtime guarantee against async stale closures. In a multi-tenant SaaS, every callback that references tenant-specific data needs this pattern.
For CitizenApp, this bug would have meant Claude API calls occasionally using wrong system prompts, potentially leaking context between tenants. It was invisible until I added rapid tenant-switching to my testing suite.
Use the ref pattern. Extract it to a custom hook. Test with rapid context changes.
Comments
No comments yet. Start the discussion.