Skip to content

Commit 2bdd976

Browse files
committed
Add React hooks for managing Ably Push Notifications
Introduced `usePush` and `usePushActivation` hooks to simplify activation and subscription management for push notifications in React. Includes detailed documentation, tests, and API reference to guide integration and error handling.
1 parent c90f1c3 commit 2bdd976

File tree

7 files changed

+681
-0
lines changed

7 files changed

+681
-0
lines changed

docs/react-push.md

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
# React Hooks for Push Notifications
2+
3+
Use Ably Push Notifications in your React application using idiomatic React Hooks.
4+
5+
Using these hooks you can:
6+
7+
- [Activate and deactivate devices](https://ably.com/docs/push/activate-subscribe) for push notifications
8+
- [Subscribe devices or clients](https://ably.com/docs/push/activate-subscribe#subscribing) to push notifications on channels
9+
- List active push subscriptions for a channel
10+
11+
> [!NOTE]
12+
> Push notifications require the Push plugin to be loaded. If you're using the modular bundle, ensure the Push plugin is included in your client options. See the [Push Notifications documentation](https://ably.com/docs/push) for general concepts and setup.
13+
14+
---
15+
16+
<!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=6 orderedList=false} -->
17+
<!-- code_chunk_output -->
18+
19+
- [Prerequisites](#prerequisites)
20+
- [usePushActivation](#usepushactivation)
21+
- [usePush](#usepush)
22+
- [Error Handling](#error-handling)
23+
- [Full Example](#full-example)
24+
- [API Reference](#api-reference)
25+
26+
## <!-- /code_chunk_output -->
27+
28+
## Prerequisites
29+
30+
Push hooks require the Ably client to be configured with the Push plugin. When using the default `ably` bundle, the Push plugin is included automatically. If you're using the modular bundle, you must provide it explicitly:
31+
32+
```jsx
33+
import * as Ably from 'ably';
34+
import Push from 'ably/push';
35+
36+
const client = new Ably.Realtime({
37+
key: 'your-ably-api-key',
38+
clientId: 'me',
39+
plugins: { Push },
40+
});
41+
42+
root.render(
43+
<AblyProvider client={client}>
44+
<App />
45+
</AblyProvider>,
46+
);
47+
```
48+
49+
---
50+
51+
## usePushActivation
52+
53+
The `usePushActivation` hook provides functions to activate and deactivate the current device for push notifications. It works directly under an `AblyProvider` and does **not** require a `ChannelProvider`.
54+
55+
```jsx
56+
import { usePushActivation } from 'ably/react';
57+
58+
const PushActivationComponent = () => {
59+
const { activate, deactivate } = usePushActivation();
60+
61+
return (
62+
<div>
63+
<button onClick={() => activate()}>Enable push notifications</button>
64+
<button onClick={() => deactivate()}>Disable push notifications</button>
65+
</div>
66+
);
67+
};
68+
```
69+
70+
#### Activation lifecycle
71+
72+
Activation registers the device with Ably's push service (on web, this requests browser notification permission and registers a service worker). The device identity is persisted to `localStorage`, so:
73+
74+
- **Activation survives page reloads and app restarts.** You do not need to call `activate()` on every mount.
75+
- **Calling `activate()` when already activated is safe** — it confirms the existing registration without side effects.
76+
- **`deactivate()` is for explicit user opt-out only.** It removes the device registration from Ably's servers and clears all persisted push state. Do not call it on unmount or app close.
77+
78+
A typical pattern is to call `activate()` once in response to a user action (e.g. tapping "Enable notifications"), not automatically on mount:
79+
80+
```jsx
81+
const NotificationBanner = () => {
82+
const { activate } = usePushActivation();
83+
const [enabled, setEnabled] = useState(false);
84+
85+
const handleEnable = async () => {
86+
try {
87+
await activate();
88+
setEnabled(true);
89+
} catch (err) {
90+
console.error('Push activation failed:', err);
91+
}
92+
};
93+
94+
if (enabled) return null;
95+
96+
return (
97+
<div className="banner">
98+
<p>Get notified about new updates</p>
99+
<button onClick={handleEnable}>Enable notifications</button>
100+
</div>
101+
);
102+
};
103+
```
104+
105+
#### Multiple clients
106+
107+
If you use multiple Ably clients via the `ablyId` pattern, pass the ID to `usePushActivation`:
108+
109+
```jsx
110+
const { activate, deactivate } = usePushActivation('providerOne');
111+
```
112+
113+
---
114+
115+
## usePush
116+
117+
The `usePush` hook provides functions to manage push notification subscriptions for a specific channel. It must be used inside a `ChannelProvider`.
118+
119+
```jsx
120+
import { usePush } from 'ably/react';
121+
122+
const PushSubscriptionComponent = () => {
123+
const { subscribeDevice, unsubscribeDevice } = usePush('your-channel-name');
124+
125+
return (
126+
<div>
127+
<button onClick={() => subscribeDevice()}>Subscribe to channel</button>
128+
<button onClick={() => unsubscribeDevice()}>Unsubscribe from channel</button>
129+
</div>
130+
);
131+
};
132+
```
133+
134+
> [!IMPORTANT]
135+
> The device must be activated (via `usePushActivation`) before calling `subscribeDevice()` or `unsubscribeDevice()`. See [Error Handling](#error-handling) for details on what happens if activation hasn't been completed.
136+
137+
#### Subscribe by device or by client
138+
139+
`usePush` supports both device-level and client-level subscriptions:
140+
141+
```jsx
142+
const {
143+
subscribeDevice, // Subscribe the current device
144+
unsubscribeDevice, // Unsubscribe the current device
145+
subscribeClient, // Subscribe all devices for the current clientId
146+
unsubscribeClient, // Unsubscribe all devices for the current clientId
147+
} = usePush('your-channel-name');
148+
```
149+
150+
- **Device subscriptions** target the specific device. Use when you want per-device control.
151+
- **Client subscriptions** target all devices that share the same `clientId`. Use when a user should receive push notifications regardless of which device they're on.
152+
153+
> [!NOTE]
154+
> `subscribeClient` and `unsubscribeClient` require the Ably client to be configured with a `clientId`. An error will be thrown if no `clientId` is set.
155+
156+
#### Listing subscriptions
157+
158+
You can list active push subscriptions for the channel:
159+
160+
```jsx
161+
const { listSubscriptions } = usePush('your-channel-name');
162+
163+
const handleListSubscriptions = async () => {
164+
const result = await listSubscriptions();
165+
console.log('Active subscriptions:', result.items);
166+
};
167+
```
168+
169+
`listSubscriptions` accepts an optional params object to filter by `deviceId` or `clientId`:
170+
171+
```jsx
172+
const result = await listSubscriptions({ deviceId: 'specific-device-id' });
173+
```
174+
175+
#### Push subscriptions are persistent
176+
177+
Unlike presence (which enters on mount and leaves on unmount), push subscriptions are **persistent server-side state**. They survive app restarts and are not automatically removed when a component unmounts. This is by design — push notifications are meant to be delivered even when your app is not running.
178+
179+
To remove a subscription, explicitly call `unsubscribeDevice()` or `unsubscribeClient()` in response to a user action.
180+
181+
---
182+
183+
## Error Handling
184+
185+
### Push plugin not loaded
186+
187+
If the Push plugin is not included in your client configuration, `usePush` will throw immediately on render:
188+
189+
```
190+
Error: Push plugin not provided (code: 40019)
191+
```
192+
193+
For `usePushActivation`, the error is thrown when `activate()` or `deactivate()` is called.
194+
195+
To fix this, ensure the Push plugin is loaded. See [Prerequisites](#prerequisites).
196+
197+
### Device not activated
198+
199+
If you call `subscribeDevice()` or `unsubscribeDevice()` before the device has been activated, the promise will reject with:
200+
201+
```
202+
Error: Cannot subscribe from client without deviceIdentityToken (code: 50000)
203+
```
204+
205+
Ensure `activate()` has completed successfully before subscribing:
206+
207+
```jsx
208+
const { activate } = usePushActivation();
209+
const { subscribeDevice } = usePush('alerts');
210+
211+
const handleEnablePush = async () => {
212+
await activate();
213+
await subscribeDevice();
214+
};
215+
```
216+
217+
### No clientId set
218+
219+
If you call `subscribeClient()` or `unsubscribeClient()` without a `clientId` configured on the Ably client, the promise will reject with:
220+
221+
```
222+
Error: Cannot subscribe from client without client ID (code: 50000)
223+
```
224+
225+
Ensure your Ably client is created with a `clientId`:
226+
227+
```jsx
228+
const client = new Ably.Realtime({ key: 'your-api-key', clientId: 'me' });
229+
```
230+
231+
### Connection and channel errors
232+
233+
Like other channel-level hooks, `usePush` returns `connectionError` and `channelError`:
234+
235+
```jsx
236+
const { subscribeDevice, connectionError, channelError } = usePush('your-channel-name');
237+
238+
if (connectionError) {
239+
return <p>Connection error: {connectionError.message}</p>;
240+
}
241+
if (channelError) {
242+
return <p>Channel error: {channelError.message}</p>;
243+
}
244+
```
245+
246+
---
247+
248+
## Full Example
249+
250+
A complete example showing activation, channel subscription, and error handling:
251+
252+
```jsx
253+
import { AblyProvider, ChannelProvider, usePushActivation, usePush } from 'ably/react';
254+
import * as Ably from 'ably';
255+
import { useState } from 'react';
256+
257+
const client = new Ably.Realtime({ key: 'your-ably-api-key', clientId: 'me' });
258+
259+
const App = () => (
260+
<AblyProvider client={client}>
261+
<PushActivation />
262+
<ChannelProvider channelName="alerts">
263+
<AlertSubscription />
264+
</ChannelProvider>
265+
</AblyProvider>
266+
);
267+
268+
const PushActivation = () => {
269+
const { activate, deactivate } = usePushActivation();
270+
const [active, setActive] = useState(false);
271+
const [error, setError] = useState(null);
272+
273+
const handleToggle = async () => {
274+
try {
275+
if (active) {
276+
await deactivate();
277+
} else {
278+
await activate();
279+
}
280+
setActive(!active);
281+
setError(null);
282+
} catch (err) {
283+
setError(err.message);
284+
}
285+
};
286+
287+
return (
288+
<div>
289+
<button onClick={handleToggle}>
290+
{active ? 'Disable' : 'Enable'} push notifications
291+
</button>
292+
{error && <p className="error">{error}</p>}
293+
</div>
294+
);
295+
};
296+
297+
const AlertSubscription = () => {
298+
const { subscribeDevice, unsubscribeDevice, connectionError, channelError } = usePush('alerts');
299+
const [subscribed, setSubscribed] = useState(false);
300+
301+
if (connectionError) return <p>Connection error: {connectionError.message}</p>;
302+
if (channelError) return <p>Channel error: {channelError.message}</p>;
303+
304+
const handleToggle = async () => {
305+
try {
306+
if (subscribed) {
307+
await unsubscribeDevice();
308+
} else {
309+
await subscribeDevice();
310+
}
311+
setSubscribed(!subscribed);
312+
} catch (err) {
313+
console.error('Subscription error:', err);
314+
}
315+
};
316+
317+
return (
318+
<button onClick={handleToggle}>
319+
{subscribed ? 'Unsubscribe from' : 'Subscribe to'} alerts
320+
</button>
321+
);
322+
};
323+
```
324+
325+
---
326+
327+
## API Reference
328+
329+
### `usePushActivation`
330+
331+
```typescript
332+
function usePushActivation(ablyId?: string): PushActivationResult;
333+
334+
interface PushActivationResult {
335+
activate: () => Promise<void>;
336+
deactivate: () => Promise<void>;
337+
}
338+
```
339+
340+
| Property | Type | Description |
341+
| ------------ | --------------------- | --------------------------------------------------------------------------- |
342+
| `activate` | `() => Promise<void>` | Activates the device for push notifications. Persists to `localStorage`. |
343+
| `deactivate` | `() => Promise<void>` | Deactivates the device and removes the registration from Ably's servers. |
344+
345+
### `usePush`
346+
347+
```typescript
348+
function usePush(channelNameOrNameAndOptions: ChannelParameters): PushResult;
349+
350+
interface PushResult {
351+
channel: Ably.RealtimeChannel;
352+
subscribeDevice: () => Promise<void>;
353+
unsubscribeDevice: () => Promise<void>;
354+
subscribeClient: () => Promise<void>;
355+
unsubscribeClient: () => Promise<void>;
356+
listSubscriptions: (params?: Record<string, string>) => Promise<PaginatedResult<PushChannelSubscription>>;
357+
connectionError: Ably.ErrorInfo | null;
358+
channelError: Ably.ErrorInfo | null;
359+
}
360+
```
361+
362+
| Property | Type | Description |
363+
| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------ |
364+
| `channel` | `Ably.RealtimeChannel` | The channel instance. |
365+
| `subscribeDevice` | `() => Promise<void>` | Subscribes the current device to push notifications on this channel. |
366+
| `unsubscribeDevice` | `() => Promise<void>` | Unsubscribes the current device from push notifications on this channel. |
367+
| `subscribeClient` | `() => Promise<void>` | Subscribes all devices for the current `clientId` to push on this channel. |
368+
| `unsubscribeClient` | `() => Promise<void>` | Unsubscribes all devices for the current `clientId` from push on this channel. |
369+
| `listSubscriptions` | `(params?) => Promise<PaginatedResult<...>>` | Lists active push subscriptions for this channel. |
370+
| `connectionError` | `Ably.ErrorInfo \| null` | Current connection error, if any. |
371+
| `channelError` | `Ably.ErrorInfo \| null` | Current channel error, if any. |

0 commit comments

Comments
 (0)