Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
171 changes: 129 additions & 42 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
<img src="https://img.shields.io/npm/dm/@saborter/react.svg" /></a>
<a href="https://github.com/TENSIILE/saborter-react/actions/workflows/publish.yml" alt="Release">
<img src="https://github.com/TENSIILE/saborter-react/actions/workflows/publish.yml/badge.svg" /></a>
<a href="https://github.com/TENSIILE/saborter-react/actions/workflows/ci.yml" alt="CI">
<img src="https://github.com/TENSIILE/saborter-react/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/TENSIILE/saborter-react/blob/develop/LICENSE" alt="License">
<img src="https://img.shields.io/badge/license-MIT-blue" /></a>
<a href="https://github.com/TENSIILE/saborter-react" alt="Github">
<img src="https://img.shields.io/badge/repository-github-color" /></a>
</p>

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

Expand All @@ -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
Expand Down Expand Up @@ -70,7 +72,7 @@ const Component = () => {
#### Props

```typescript
const { aborter } = new useAborter(props?: UseAborterProps);
const { aborter } = useAborter(props?: UseAborterProps);
```

#### Props Parameters
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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 <h1>{loading ? 'Loading...' : user.fullname}</h1>;
};
```

### 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 <div>Child component</div>;
}

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 (
<div>
Parent Component
<Child aborter={aborter}>
</div>
);
};
```

### 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 (
<div>
<button onClick={handleFirstClick}>First abort</button>
<button onClick={handleSecondClick}>Second abort</button>
</div>
);
};
```

## 📋 License
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './use-aborter';
export * from './use-reusable-aborter';
export * from './use-abort-when-unmount';
1 change: 1 addition & 0 deletions src/hooks/use-abort-when-unmount/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './use-abort-when-unmount';
26 changes: 26 additions & 0 deletions src/hooks/use-abort-when-unmount/use-abort-when-unmount.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
25 changes: 14 additions & 11 deletions src/hooks/use-aborter/use-aborter.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,42 @@
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 => {
const { onAbort, onStateChange, dispose = true } = props;

const aborterRef = useRef(new Aborter({ onAbort: onAbort as any, onStateChange }));
const [requestState, setRequestState] = useState<RequestState | null>(null);
const [loading, setLoading] = useState<boolean>(false);

const isDisposeEnabledRef = Shared.Hooks.useMutableRef(dispose);

useEffect(() => {
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 };
};
Loading
Loading