DEV Community

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.