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 asubscriptionKeyfor deduplication and anunsubscribemode ('auto'to unsubscribe on completion/error,'never'to keep alive)onDatareturn 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
subscriptionKeyprevents 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