DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

React 19 useCallback Dependencies in Multi-Tenant Dashboards: Why Your AI Feature Memoization Is Causing Stale Closures

React 19 useCallback Dependencies in Multi-Tenant Dashboards: Why Your AI Feature Memoization Is Causing Stale Closures

I've shipped CitizenApp to production with 9 concurrent AI features across 200+ tenant accounts. Last month, I spent 6 hours debugging why Tenant B's Claude API calls were occasionally using Tenant A's system prompts. The root cause? A useCallback dependency list that worked fine in my single-tenant prototype but became a data-leaking landmine once multi-tenant context entered the picture.

This is the invisible bug that doesn't show up in staging. It only surfaces under specific conditions: rapid tenant switches, slow network responses, or concurrent feature toggles. Your error logs stay clean. Your TypeScript types pass. But somewhere, a stale closure is capturing yesterday's tenant ID.

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. User switches tenants (common in admin dashboards or managed accounts)
  2. AI feature request is in-flight when the switch happens
  3. Child component still holds the old memoized callback
  4. 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>;
};
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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
  // ...
}, []);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)