-A library for canceling asynchronous requests that combines the `Saborter` library and `React`.
+A library for canceling asynchronous requests that combines the [Saborter](https://github.com/TENSIILE/saborter) library and [React](https://github.com/facebook/react).
## 📚 Documentation
@@ -29,9 +31,9 @@ The documentation is divided into several sections:
## 📦 Installation
```bash
-npm install @saborter/react
+npm install saborter @saborter/react
# or
-yarn add @saborter/react
+yarn add saborter @saborter/react
```
## 📖 Possibilities
@@ -70,7 +72,7 @@ const Component = () => {
#### Props
```typescript
-const { aborter } = new useAborter(props?: UseAborterProps);
+const { aborter } = useAborter(props?: UseAborterProps);
```
#### Props Parameters
@@ -141,7 +143,7 @@ console.log(requestState); // 'cancelled' / 'pending' / 'fulfilled' / 'rejected'
```typescript
// The type can be found in `saborter/types`
-const reusableAborter = new useReusableAborter(props?: ReusableAborterProps);
+const reusableAborter = useReusableAborter(props?: ReusableAborterProps);
```
#### Props Parameters
@@ -198,16 +200,16 @@ Immediately cancels the currently executing request.
```javascript
import { useState } from 'react';
-import { AbortError } from 'saborter';
+import { AbortError } from 'saborter/errors';
import { useAborter } from '@saborter/react';
const Component = () => {
- const [user, setUser] = useState(null);
- const [loading, setLoading] = useState(false);
-
// Create an Aborter instance via the hook
const { aborter } = useAborter();
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(false);
+
// Use for the request
const fetchData = async () => {
try {
@@ -216,7 +218,8 @@ const Component = () => {
setUser(user);
} catch (error) {
if (error instanceof AbortError) {
- // An abort error will occur either when the `aborter.abort()` method is called or when the component is unmounted.
+ // An abort error will occur either when the `aborter.abort()` method is called
+ // or when the component is unmounted.
console.error('Abort error:', error);
}
console.error('Request error:', error);
@@ -229,58 +232,142 @@ const Component = () => {
};
```
+### Using internal `loading` state
+
+```javascript
+import { useState } from 'react';
+import { AbortError } from 'saborter/errors';
+import { useAborter } from '@saborter/react';
+
+const Component = () => {
+ // Create an Aborter instance via the hook
+ const { aborter, loading } = useAborter();
+
+ const [user, setUser] = useState(null);
+
+ // Use for the request
+ const fetchData = async () => {
+ try {
+ const user = await aborter.try((signal) => fetch('/api/user', { signal }));
+ setUser(user);
+ } catch (error) {
+ if (error instanceof AbortError) {
+ // An abort error will occur either when the `aborter.abort()` method is called
+ // or when the component is unmounted.
+ console.error('Abort error:', error);
+ }
+ console.error('Request error:', error);
+ }
+ };
+
+ return
{loading ? 'Loading...' : user.fullname}
;
+};
+```
+
### The `AbortError` `initiator` changed while unmounting the component
```javascript
-import { AbortError } from 'saborter';
+import { AbortError } from 'saborter/errors';
import { useAborter } from '@saborter/react';
const Component = () => {
+ const { aborter } = useAborter({
+ onAbort: (error) => {
+ if (error.type === 'aborted' && error.initiator === 'component-unmounted') {
+ console.log('Component is unmounted!');
+ }
+ }
+ });
+
+ const fetchData = async () => {
+ const user = await aborter.try((signal) => fetch('/api/user', { signal }));
+ };
+};
+```
+
+### Request interruption when unmounting a component with an external `aborter`
+
+If you have an `aborter` instance that was created behind a component, for example, in a parent component, but you want to abort the request when the child is unmounted, you can use the `useAbortWhenUnmount` hook.
+
+```tsx
+import { useAborter, useAbortWhenUnmount } from '@saborter/react';
+
+const Child = ({ aborter }) => {
+ useAbortWhenUnmount(aborter);
+
+ return
Child component
;
+}
+
+const Parent = () => {
const { aborter } = useAborter();
+ // Use for the request
const fetchData = async () => {
try {
const user = await aborter.try((signal) => fetch('/api/user', { signal }));
+ setUser(user);
} catch (error) {
- if (error instanceof AbortError) {
- console.error('Abort error initiator:', error.initiator); // 'component-unmounted';
+ if (error instanceof AbortError && error.initiator === 'component-unmounted') {
+ // handling request interruption due to component unmounting
}
}
};
+
+ return (
+
+ Parent Component
+
+
+ );
};
```
### Using `useReusableAborter`
-```typescript
-const aborter = new useReusableAborter();
-
-// Get the current signal
-const signal = aborter.signal;
-
-// Attach listeners
-signal.addEventListener('abort', () => console.log('Listener 1'));
-signal.addEventListener('abort', () => console.log('Listener 2'), { once: true }); // won't be recovered
-
-// Set onabort handler
-signal.onabort = () => console.log('Onabort handler');
-
-// First abort
-aborter.abort('First reason');
-// Output:
-// Listener 1
-// Listener 2 (once)
-// Onabort handler
-
-// The signal is now a fresh one, but the non‑once listeners and onabort are reattached
-signal.addEventListener('abort', () => console.log('Listener 3')); // new listener, will survive next abort
-
-// Second abort
-aborter.abort('Second reason');
-// Output:
-// Listener 1
-// Onabort handler
-// Listener 3
+```tsx
+import { useEffect } from 'react';
+import { useReusableAborter } from '@saborter/react';
+
+const Component = () => {
+ const aborter = useReusableAborter();
+
+ useEffect(() => {
+ // Attach listeners
+ aborter.signal.addEventListener('abort', () => console.log('Listener 1'));
+ aborter.signal.addEventListener('abort', () => console.log('Listener 2'), { once: true }); // won't be recovered
+
+ // Set onabort handler
+ aborter.signal.onabort = () => console.log('Onabort handler');
+ }, []);
+
+ const handleFirstClick = () => {
+ // First abort
+ aborter.abort('First reason');
+ // Output:
+ // Listener 1
+ // Listener 2 (once)
+ // Onabort handler
+
+ // The signal is now a fresh one, but the non‑once listeners and onabort are reattached
+ aborter.signal.addEventListener('abort', () => console.log('Listener 3')); // new listener, will survive next abort
+ };
+
+ const handleSecondClick = () => {
+ // Second abort
+ aborter.abort('Second reason');
+ // Output:
+ // Listener 1
+ // Onabort handler
+ // Listener 3
+ };
+
+ return (
+
+
+
+
+ );
+};
```
## 📋 License
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 8ca50fb..18ed4e1 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,2 +1,3 @@
export * from './use-aborter';
export * from './use-reusable-aborter';
+export * from './use-abort-when-unmount';
diff --git a/src/hooks/use-abort-when-unmount/index.ts b/src/hooks/use-abort-when-unmount/index.ts
new file mode 100644
index 0000000..04ef88b
--- /dev/null
+++ b/src/hooks/use-abort-when-unmount/index.ts
@@ -0,0 +1 @@
+export * from './use-abort-when-unmount';
diff --git a/src/hooks/use-abort-when-unmount/use-abort-when-unmount.ts b/src/hooks/use-abort-when-unmount/use-abort-when-unmount.ts
new file mode 100644
index 0000000..9a8f1ac
--- /dev/null
+++ b/src/hooks/use-abort-when-unmount/use-abort-when-unmount.ts
@@ -0,0 +1,26 @@
+import { useEffect } from 'react';
+import { Aborter } from 'saborter';
+import * as Shared from '../../shared';
+import { createAbortableUnmountError } from '../use-aborter/use-aborter.utils';
+
+/**
+ * React hook that automatically aborts an `Aborter` instance when the component unmounts.
+ *
+ * The hook creates a mutable reference to the `aborter` instance and sets up a `useEffect`
+ * cleanup function that calls `aborter.abort()` with a special `AbortError` indicating
+ * that the operation was aborted due to unmount.
+ *
+ * @param aborter - The `Aborter` instance to be aborted on unmount.
+ * @returns void
+ */
+export const useAbortWhenUnmount = (aborter: Aborter): void => {
+ const aborterRef = Shared.Hooks.useMutableRef(aborter);
+
+ useEffect(() => {
+ const aborterCurrent = aborterRef.current;
+
+ return () => {
+ aborterCurrent.abort(createAbortableUnmountError());
+ };
+ }, [aborterRef]);
+};
diff --git a/src/hooks/use-aborter/use-aborter.ts b/src/hooks/use-aborter/use-aborter.ts
index 92a45e9..e97af1b 100644
--- a/src/hooks/use-aborter/use-aborter.ts
+++ b/src/hooks/use-aborter/use-aborter.ts
@@ -1,10 +1,9 @@
import { useRef, useEffect, useState } from 'react';
import { Aborter } from 'saborter';
-import { AbortError } from 'saborter/errors';
import { RequestState } from 'saborter/types';
import { dispose as disposeFn } from 'saborter/lib';
import * as Shared from '../../shared';
-import * as Constants from './use-aborter.constants';
+import { createAbortableUnmountError } from './use-aborter.utils';
import * as Types from './use-aborter.types';
export const useAborter = (props: Types.UseAborterProps = {}): Types.UseAborterResult => {
@@ -12,6 +11,7 @@ export const useAborter = (props: Types.UseAborterProps = {}): Types.UseAborterR
const aborterRef = useRef(new Aborter({ onAbort: onAbort as any, onStateChange }));
const [requestState, setRequestState] = useState(null);
+ const [loading, setLoading] = useState(false);
const isDisposeEnabledRef = Shared.Hooks.useMutableRef(dispose);
@@ -19,21 +19,24 @@ export const useAborter = (props: Types.UseAborterProps = {}): Types.UseAborterR
const currentAborter = aborterRef.current;
const isDisposeEnabledCurrent = isDisposeEnabledRef.current;
- const unsubscribe = currentAborter.listeners.state.subscribe(setRequestState);
+ const unsubscribeRequestState = currentAborter.listeners.state.subscribe(setRequestState);
+
+ const unsubscribeLoading = currentAborter.listeners.state.subscribe((state) => {
+ setLoading(state === 'pending');
+ });
return () => {
- unsubscribe();
- currentAborter.abort(
- new AbortError(Constants.ABORTED_SIGNAL_WITHOUT_MESSAGE, {
- type: 'aborted',
- initiator: Constants.ABORTABLE_UNMOUNTED_INITIATOR
- })
- );
+ unsubscribeRequestState();
+ unsubscribeLoading();
+
+ const unmountAbortError = createAbortableUnmountError();
+ currentAborter.abort(unmountAbortError);
+
if (isDisposeEnabledCurrent) {
disposeFn(currentAborter);
}
};
}, [isDisposeEnabledRef]);
- return { aborter: aborterRef.current, requestState };
+ return { aborter: aborterRef.current, requestState, loading };
};
diff --git a/src/hooks/use-aborter/use-aborter.types.ts b/src/hooks/use-aborter/use-aborter.types.ts
index 87e0d13..2db1a3f 100644
--- a/src/hooks/use-aborter/use-aborter.types.ts
+++ b/src/hooks/use-aborter/use-aborter.types.ts
@@ -1,10 +1,26 @@
import { Aborter } from 'saborter';
-import { OnAbortCallback, OnStateChangeCallback, RequestState, AbortInitiator as Initiator } from 'saborter/types';
+import { AbortError as AbortErrorImpl } from 'saborter/errors';
+import { OnStateChangeCallback, RequestState, AbortInitiator as Initiator } from 'saborter/types';
import { ABORTABLE_UNMOUNTED_INITIATOR } from './use-aborter.constants';
+/**
+ * When the error is triggered by a `timeout`, it means that automatic request cancellation was configured and the cancellation was successful.
+
+ When the error is triggered by the `user`, it means that the user interrupted the request by calling the `abort()` method.
+
+ When the error is triggered by the `system`, it means that you caught an error canceling a previous request.
+
+ When the error is triggered by the `component-unmounted`, it means that the request was interrupted because the component in which the hook was initialized was unmounted.
+ */
+export type AbortInitiator = Initiator | typeof ABORTABLE_UNMOUNTED_INITIATOR;
+
+interface AbortError extends AbortErrorImpl {
+ initiator?: AbortInitiator;
+}
+
export interface UseAborterResult {
/**
- * `Aborter` class instance
+ * `Aborter` class instance.
*/
aborter: Aborter;
/**
@@ -20,6 +36,10 @@ export interface UseAborterResult {
@default null
*/
requestState: RequestState | null;
+ /**
+ * a status indicating that the request is still being processed.
+ */
+ loading: boolean;
}
export interface UseAborterProps {
@@ -28,7 +48,7 @@ export interface UseAborterProps {
Associated with EventListener.onabort.
It can be overridden via `aborter.listeners.onabort`
*/
- onAbort?: OnAbortCallback;
+ onAbort?: (error: AbortError) => void;
/**
A function called when the request state changes.
It takes the new state as an argument.
@@ -42,14 +62,3 @@ export interface UseAborterProps {
*/
dispose?: boolean;
}
-
-/**
- * When the error is triggered by a `timeout`, it means that automatic request cancellation was configured and the cancellation was successful.
-
- When the error is triggered by the `user`, it means that the user interrupted the request by calling the abort() method.
-
- When the error is triggered by the `system`, it means that you caught an error canceling a previous request.
-
- When the error is triggered by the `component-unmounted`, it means that the request was interrupted because the component in which the hook was initialized was unmounted.
- */
-export type AbortInitiator = Initiator | typeof ABORTABLE_UNMOUNTED_INITIATOR;
diff --git a/src/hooks/use-aborter/use-aborter.utils.ts b/src/hooks/use-aborter/use-aborter.utils.ts
new file mode 100644
index 0000000..35b6d43
--- /dev/null
+++ b/src/hooks/use-aborter/use-aborter.utils.ts
@@ -0,0 +1,20 @@
+import { AbortError } from 'saborter/errors';
+import * as Constants from './use-aborter.constants';
+
+/**
+ * Creates an `AbortError` specifically for scenarios where an operation is aborted
+ * because the component or context it belongs to unmounted.
+ *
+ * This error is typically used in React components or similar environments where
+ * asynchronous operations should be cancelled when the component is removed from
+ * the DOM. The error includes a predefined message and sets the `type` to `'aborted'`
+ * and `initiator` to a constant indicating unmount.
+ *
+ * @returns {AbortError} A new `AbortError` instance with preset message, type, and initiator.
+ */
+export const createAbortableUnmountError = (): AbortError => {
+ return new AbortError(Constants.ABORTED_SIGNAL_WITHOUT_MESSAGE, {
+ type: 'aborted',
+ initiator: Constants.ABORTABLE_UNMOUNTED_INITIATOR
+ });
+};
diff --git a/vite.config.js b/vite.config.js
index 2ebfe00..9a1b0f5 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -49,7 +49,7 @@ export default defineConfig({
}
},
rollupOptions: {
- external: ['react', 'react-dom', 'react/jsx-runtime'],
+ external: ['react', 'react-dom', 'react/jsx-runtime', /^saborter(\/.*)?$/],
output: {
preserveModules: false,
preserveModulesRoot: 'src',