Skip to main content

Subscription Manager Pattern

Architecture pattern for managing GraphQL subscriptions in React with automatic deduplication and lifecycle control.

🎯 Problem​

In a complex application with real-time features, managing GraphQL subscriptions can become problematic:

  • Navigating between pages creates duplicate subscriptions to the same event
  • Subscriptions are tied to component lifecycle, making cross-page tracking difficult
  • No centralized way to manage active subscriptions
  • Hard to control when a subscription should automatically unsubscribe (on success, on error, or never)

Apollo Client's useSubscription hook does not address these concerns — it is bound to the component lifecycle (unmounting kills the subscription), and two components subscribing to the same query will create two independent WebSocket subscriptions. There is no built-in deduplication or cross-page subscription management (Apollo docs).

📂 Architecture​

src/
├── features/****
│ └── subscriptions/
│ ├── SubscriptionsHandler.tsx
│ └── types.ts

📜 Implementation​

1. Types​

import type { DocumentNode, OnDataOptions, SubscriptionHookOptions, SubscriptionOptions } from '@apollo/client';

type CustomSubscribeOptions = { subscriptionKey?: string; unsubscribe?: 'auto' | 'never' };
type CustomSubscriptionHookOptions = Omit<SubscriptionHookOptions, 'onData'> & {
onData?: (options: OnDataOptions) => any | { shouldUnsubscribe?: boolean };
};
type Subscription = SubscriptionOptions & CustomSubscriptionHookOptions & CustomSubscribeOptions;
type Subscriptions = Map<string, Subscription>;
type Subscribe = (
query: DocumentNode,
hookOptions: CustomSubscriptionHookOptions,
subscribeOptions: CustomSubscribeOptions,
) => string;
type Unsubscribe = (key: string) => void;

Key type decisions:

  • CustomSubscribeOptions: Adds a subscriptionKey for deduplication and an unsubscribe mode ('auto' to unsubscribe on completion/error, 'never' to keep alive)
  • onData return type: Can optionally return { shouldUnsubscribe: true } to trigger unsubscription from within the data handler

2. SubscriptionsHandler​

The central provider that manages all active subscriptions:

import type { DocumentNode, OnDataOptions, SubscriptionHookOptions, SubscriptionOptions } from '@apollo/client';
import { useSubscription } from '@apollo/client';
import type { Context, PropsWithChildren } from 'react';
import { createContext, useContext, useMemo, useState } from 'react';
import { v4 as uuid } from 'uuid';

interface SubscriptionsHandlerType {
subscribe: Subscribe;
unsubscribe: Unsubscribe;
}

const defaultContext: SubscriptionsHandlerType = {
subscribe: () => '',
unsubscribe: () => null,
};

export const SubscriptionsHandlerContext = createContext<SubscriptionsHandlerType>(defaultContext);

export const useSubscriptions = () => {
const context = useContext<SubscriptionsHandlerType>(
SubscriptionsHandlerContext as unknown as Context<SubscriptionsHandlerType>,
);

if (context === undefined) {
throw new Error('useSubscriptions must be used within SubscriptionsHandler');
}

return context;
};

export const SubscriptionsHandler = ({ children }: PropsWithChildren<unknown>) => {
const [subscriptions, setSubscriptions] = useState<Subscriptions>(new Map());

const subscribe: Subscribe = (query, hookOptions, subscribeOptions) => {
const key = subscribeOptions.subscriptionKey || uuid();

setSubscriptions((prev) => {
if (prev.has(key)) {
return prev; // already subscribed, deduplicate
}
const newSubscriptions = new Map(prev);
newSubscriptions.set(key, { query, ...hookOptions, ...subscribeOptions });
return newSubscriptions;
});

return key;
};

const unsubscribe: Unsubscribe = (key: string) => {
setSubscriptions((prev) => {
if (!prev.has(key)) return prev;

const newSubscriptions = new Map(prev);
newSubscriptions.delete(key);

return newSubscriptions;
});
};

const value = useMemo(() => ({ subscribe, unsubscribe }), [subscribe, unsubscribe]);

return (
<SubscriptionsHandlerContext.Provider value={value}>
<SubscriptionManager subscriptions={subscriptions} />
{children}
</SubscriptionsHandlerContext.Provider>
);
};

The deduplication happens in subscribe: if a subscription with the same key already exists in the Map, it returns early without creating a duplicate.

3. SubscriptionManager & ActiveSubscription​

These internal components handle the actual Apollo useSubscription calls:

const SubscriptionManager = ({ subscriptions }: { subscriptions: Subscriptions }) => {
const { unsubscribe } = useSubscriptions();

return (
<>
{Array.from(subscriptions.entries()).map(([key, subscription]) => (
<ActiveSubscription key={key} subscription={subscription} unsubscribe={() => unsubscribe(key)} />
))}
</>
);
};

interface ActiveSubscriptionProps {
subscription: Subscription;
unsubscribe: () => void;
}

const ActiveSubscription = ({
subscription: { query, unsubscribe: unsubscribeOption, onData, onError, ...options },
unsubscribe,
}: ActiveSubscriptionProps) => {
useSubscription(query, {
...options,
onData: (data) => {
const feedback = onData?.(data);
if (unsubscribeOption === 'auto' || feedback?.shouldUnsubscribe === true) {
unsubscribe();
}
},
onError: (error) => {
onError?.(error);
if (unsubscribeOption === 'auto') {
unsubscribe();
}
},
});

return null;
};

Each active subscription is rendered as a component so that React manages the useSubscription hook lifecycle. When a subscription is removed from the map, the component unmounts and the WebSocket subscription is automatically cleaned up.

4. Usage​

Wrap your app (or a subtree) with the provider:

function App() {
return (
<SubscriptionsHandler>
<Router />
</SubscriptionsHandler>
);
}

Then subscribe from any component. The subscriptionKey ensures deduplication across pages:

function useDocumentGenerationSubscription(id: string) {
const { subscribe } = useSubscriptions();

const { data } = useQuery(DOCUMENT_STATUS, {
fetchPolicy: 'cache-and-network',
variables: { id },
});
const status = /* get status from data */;

useEffect(() => {
subscribe(
SUBSCRIBE_TO_DOCUMENT_STATUS,
{
variables: { filters: { id } },
onData: ({ data: { data }, client: { cache } }) => {
if (!data) return;

const { status: newStatus } = /* get status from data */;

// Update Apollo cache
updateInGraphQLCache(
cache,
{ id: id, __typename: 'YourTypename' },
{
/* update status property here */
},
);

// Unsubscribe if needed
if (['Completed', 'Failed'].includes(newStatus)) {
return { shouldUnsubscribe: true };
}
},
},
// Same key across pages → no duplicate subscription
{ subscriptionKey: `document-status-${id}` },
);
}, [id, status]);
}

In this example, if the user navigates from page A to page B while a document is being generated, both pages call subscribe with the same subscriptionKey. The second call is a no-op — the existing subscription keeps running and updating the Apollo cache, which both pages read from.

✅ Benefits​

  • Deduplication: Same subscriptionKey prevents duplicate WebSocket subscriptions across pages
  • Centralized lifecycle: Subscriptions live in the provider, independent of individual component lifecycles
  • Flexible unsubscription: Choose between 'auto' (unsubscribe on completion/error), 'never', or manual via { shouldUnsubscribe: true }
  • Apollo-native: Builds on top of useSubscription, keeping cache updates and error handling consistent

🧩 Possible Extensions​

  • Add subscription status tracking (active, completed, errored)
  • Support subscription reconnection on network errors
  • Add a useSubscriptionStatus(key) hook to observe a subscription's state from any component
  • Support priority levels for subscriptions