diff --git a/package-lock.json b/package-lock.json index 4cdce54..752d4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@saborter/react", - "version": "1.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@saborter/react", - "version": "1.0.0", + "version": "2.1.0", "license": "MIT", "devDependencies": { "@types/node": "^25.0.3", diff --git a/package.json b/package.json index ccddec6..97a5351 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@saborter/react", - "version": "2.0.0", + "version": "2.1.0", "description": "A library for canceling asynchronous requests with React integration", "type": "module", "publishConfig": { diff --git a/readme.md b/readme.md index 9eea17b..14e9d33 100644 --- a/readme.md +++ b/readme.md @@ -7,13 +7,15 @@ + +

-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',