real-cancellable-promise
A simple cancellable promise implementation for JavaScript and TypeScript.
Read the announcement post for a full explanation. In particular, see the "Prior art" section for a comparison to existing cancellable promise libraries.
- ⚛ Built with React in mind — no more "setState after unmount" warnings!
- ⚡ Compatible with fetch, axios, and
jQuery.ajax
- 🐦 Lightweight — zero dependencies and less than 1 kB minified and gzipped
- 🏭 Used in production by Interface
Technologies
- 💻 Optimized for TypeScript
- 🔎 Compatible with
react-query
query cancellation out of the box
The Basics
yarn add real-cancellable-promise
import { CancellablePromise } from 'real-cancellable-promise'
const cancellablePromise = new CancellablePromise(normalPromise, cancel)
cancellablePromise.cancel()
await cancellablePromise
Important
The CancellablePromise
constructor takes in a promise
and a cancel
function.
Your cancel
function MUST cause promise
to reject with a Cancellation
object.
This will NOT work, your callbacks with still run:
new CancellablePromise(normalPromise, () => {})
Usage with HTTP Libraries
How do I convert a normal Promise
to a CancellablePromise
?
export function cancellableFetch(
input: RequestInfo,
init: RequestInit = {}
): CancellablePromise<Response> {
const controller = new AbortController()
const promise = fetch(input, {
...init,
signal: controller.signal,
}).catch((e) => {
if (e.name === 'AbortError') {
throw new Cancellation()
}
throw e
})
return new CancellablePromise<Response>(promise, () => controller.abort())
}
const cancellablePromise = cancellableFetch(url, {
})
fetch
with response handling
export function cancellableFetch<T>(
input: RequestInfo,
init: RequestInit = {}
): CancellablePromise<T> {
const controller = new AbortController()
const promise = fetch(input, {
...init,
signal: controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`Fetch failed with status code ${response.status}.`)
}
if (response.headers.get('content-type')?.includes('application/json')) {
return response.json()
} else {
return response.text()
}
})
.catch((e) => {
if (e.name === 'AbortError') {
throw new Cancellation()
}
throw e
})
return new CancellablePromise<T>(promise, () => controller.abort())
}
export function cancellableAxios<T>(config: AxiosRequestConfig): CancellablePromise<T> {
const source = axios.CancelToken.source()
config = { ...config, cancelToken: source.token }
const promise = axios(config)
.then((response) => response.data)
.catch((e) => {
if (e instanceof axios.Cancel) {
throw new Cancellation()
}
throw e
})
return new CancellablePromise<T>(promise, () => source.cancel())
}
const cancellablePromise = cancellableAxios({ url })
export function cancellableJQueryAjax<T>(
settings: JQuery.AjaxSettings
): CancellablePromise<T> {
const xhr = $.ajax(settings)
const promise = xhr.catch((e) => {
if (e.statusText === 'abort') throw new Cancellation()
throw e
})
return new CancellablePromise<T>(promise, () => xhr.abort())
}
const cancellablePromise = cancellableJQueryAjax({ url, dataType: 'json' })
CodeSandbox: HTTP
libraries
CancellablePromise
supports all the methods that the normal Promise
object
supports, except Promise.any
(ES2021). See the API
Reference for details.
Examples
React: Prevent setState after unmount
React will give you a warning if you attempt to update a component's state after
it has unmounted. This will happen if your component makes an API call but gets
unmounted before the API call completes.
You can fix this by canceling the API call in the cleanup function of an effect.
function listBlogPosts(): CancellablePromise<Post[]> {
}
export function Blog() {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
const cancellablePromise = listBlogPosts().then(setPosts).catch(console.error)
return cancellablePromise.cancel
}, [])
return (
<div>
{posts.map((p) => {
/* ... */
})}
</div>
)
}
CodeSandbox: prevent setState after
unmount
React: Cancel the in-progress API call when query parameters change
Sometimes API calls have parameters, like a search string entered by the user.
function searchUsers(searchTerm: string): CancellablePromise<User[]> {
}
export function UserList() {
const [searchTerm, setSearchTerm] = useState('')
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
const cancellablePromise = searchUsers(searchTerm)
.then(setUsers)
.catch(console.error)
return cancellablePromise.cancel
}, [searchTerm])
return (
<div>
<SearchInput searchTerm={searchTerm} onChange={setSearchTerm} />
{users.map((u) => {
/* ... */
})}
</div>
)
}
CodeSandbox: cancel the in-progress API call when query parameters
change
Combine multiple API calls into a single async flow
The utility function buildCancellablePromise
lets you capture
every
cancellable operation in a multi-step process. In this example, if bigQuery
is
canceled, each of the 3 API calls will be canceled (though some might have
already completed).
function bigQuery(userId: number): CancellablePromise<QueryResult> {
return buildCancellablePromise(async (capture) => {
const userPromise = api.user.get(userId)
const rolePromise = api.user.listRoles(userId)
const [user, roles] = await capture(
CancellablePromise.all([userPromise, rolePromise])
)
const customer = await capture(api.customer.get(user.customerId))
return { user, roles, customer }
})
}
Usage with react-query
If your query key changes and there's an API call in progress, react-query
will cancel the CancellablePromise
automatically.
CodeSandbox: react-query
integration
Handling Cancellation
Usually, you'll want to ignore Cancellation
objects that get thrown:
try {
await capture(cancellablePromise)
} catch (e) {
if (e instanceof Cancellation) {
return
}
}
Handling promises that can't truly be canceled
Sometimes you need to call an asynchronous function that doesn't support
cancellation. In this case, you can use pseudoCancellable
:
const cancellablePromise = pseudoCancellable(normalPromise)
cancellablePromise.cancel()
await cancellablePromise
CancellablePromise.delay
await CancellablePromise.delay(1000)
React: useCancellablePromiseCleanup
Here's a React hook that facilitates cancellation of CancellablePromise
s that
occur outside of useEffect
. Any captured API calls will be canceled when the
component unmounts. (Just be sure this is what you want to happen.)
export function useCancellablePromiseCleanup(): CaptureCancellablePromise {
const cancellablePromisesRef = useRef<CancellablePromise<unknown>[]>([])
useEffect(
() => () => {
for (const promise of cancellablePromisesRef.current) {
promise.cancel()
}
},
[]
)
const capture: CaptureCancellablePromise = useCallback((promise) => {
cancellablePromisesRef.current.push(promise)
return promise
}, [])
return capture
}
Then in your React components...
function updateUser(id: number, name: string): CancellablePromise<void> {
}
export function UserDetail(props: UserDetailProps) {
const capture = useCancellablePromiseCleanup()
async function saveChanges(): Promise<void> {
try {
await capture(updateUser(id, name))
} catch {
}
}
return <div>...</div>
}
CodeSandbox:
useCancellablePromiseCleanup
Supported Platforms
Browser: anything that's not Internet Explorer.
React Native / Expo: should work in any recent release. AbortController
has been available since 0.60.
Node.js: 14+. AbortController
is only available in Node 15+.
License
MIT
Contributing
See CONTRIBUTING.md
.