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
46 changes: 26 additions & 20 deletions docs/use-async-effect.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,31 @@ Enables asynchronous effects with some guardrails to protect against memory-leak
## Example

```tsx
useAsyncEffect(async (ctx) => {
ctx.hello = 'world'; // Attach anything you like to the context object!
return fetch('...').then(res => res.json());
}, [/* effect dependencies */])
.fulfilled((value, ctx) => {
// Equivalent to `Promise.then`
console.log(value); // Do something with your JSON!
console.log(ctx.hello) // => 'world'
})
.rejected((reason, ctx) => {
// Equivalent to `Promise.catch`
// ...
})
.settled((ctx) => {
// Equivalent to `Promise.finally`
// ...
})
.cleanup((ctx) => {
// Defines the same behavior as a function returned by `useEffect`
// ...
useAsyncEffect(() => {
return {
execute: async (signal) => {
return fetch('...', { signal }).then(res => res.json());
},

onFulfilled: (value) => {
// Equivalent to `Promise.then`
console.log(value); // Do something stateful with your JSON!
},

onRejected: (reason) => {
// Equivalent to `Promise.catch`
// ...
},

onSettled: () => {
// Equivalent to `Promise.finally`
// ...
},

onCleanup: () => {
// Defines the same behavior as a function returned by `useEffect`
// ...
},
}
});
```
2 changes: 1 addition & 1 deletion docs/use-hash.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `useHash`

Returns an MD5-compatible hash of any value given to this hook as its argument. Most client-side structures are hashable based on their serialized contents. This is handy for translating complex objects into a primitive string value for accurately detecting updates between render ticks as a dependency given to `useEffect`.
Returns an MD5-compliant hash of any value given to this hook as its argument. Most client-side structures are hashable based on their serialized contents. This is handy for translating complex objects into a primitive string value for accurately detecting updates between renders as a dependency provided to `useEffect`.

## Example

Expand Down
93 changes: 27 additions & 66 deletions src/hooks/use-async-effect/index.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,69 @@
import { DependencyList, useEffect, useCallback, useRef, useMemo } from 'react';
import { DependencyList, useEffect, useMemo, useRef } from 'react';
import { useIsMounted } from '../use-is-mounted';

type NonVoid<T> = T extends void ? never : T;

type NarrowedAsyncEffect<TContext, TResult, TVisited extends string | void, TNarrow extends string> = Omit<
AsyncEffect<TContext, TResult, TVisited | TNarrow>,
NonVoid<TVisited> | TNarrow
>;
export interface AsyncEffectInit<ResultType> {
/**
* Do something async!
*/
execute: (signal: AbortSignal) => Promise<ResultType>;

export interface AsyncEffect<TContext, TResult, TVisited extends string | void = void> {
/**
* Registers a callback to execute when the async handler resolves. Analagous
* to `Promise.then`.
*/
fulfilled(
onfulfilled?: ((value: TResult, context: Partial<TContext>) => void) | null,
): NarrowedAsyncEffect<TContext, TResult, TVisited, 'fulfilled'>;
onFulfilled?: ((value: ResultType) => void) | null;

/**
* Registers a callback to execute when the async handler rejects. Analagous
* to `Promise.catch`.
*/
rejected(
onrejected?: ((reason: any, context: Partial<TContext>) => void) | null,
): NarrowedAsyncEffect<TContext, TResult, TVisited, 'rejected'>;
onRejected?: ((reason: any) => void) | null;

/**
* Registers a callback to execute when the async handler completes, whether
* successfully or not. Analagous to `Promise.finally`.
*/
settled(
onsettled?: ((context: Partial<TContext>) => void) | null,
): NarrowedAsyncEffect<TContext, TResult, TVisited, 'settled'>;
onSettled?: (() => void) | null;

/**
* Registers a callback to execute when the underlying `React.useEffect` is
* cleaned up.
*/
cleanup(
oncleanup?: ((context: Partial<TContext>) => void) | null,
): NarrowedAsyncEffect<TContext, TResult, TVisited, 'cleanup'>;
onCleanup?: (() => void) | null;
}

/**
* Makes asynchronous work inside the React lifecycle easy with automatic guards
* against updating internal component state if the component is unmounted
* before the async work is finished.
*/
export function useAsyncEffect<TContext extends Record<string, any> = Record<string, any>, TResult = any>(
handler: (context: Partial<TContext>) => Promise<TResult>,
export function useAsyncEffect<ResultType = any>(
initFactory: () => AsyncEffectInit<ResultType>,
deps?: DependencyList,
): AsyncEffect<TContext, TResult> {
const thenCallback = useRef<any>();
const registerThenCb = useCallback((onfulfilled) => {
thenCallback.current = onfulfilled;
return chain;
}, []);

const catchCallback = useRef<any>();
const registerCatchCb = useCallback((onrejected) => {
catchCallback.current = onrejected;
return chain;
}, []);

const finallyCallback = useRef<any>();
const registerFinallyCb = useCallback((onsettled) => {
finallyCallback.current = onsettled;
return chain;
}, []);

const cleanupCallback = useRef<any>();
const registerCleanupCb = useCallback((oncleanup) => {
cleanupCallback.current = oncleanup;
return chain;
}, []);

const chain: any = useMemo(() => {
return {
fullfilled: registerThenCb, // backwards compat for spelling mistake
fulfilled: registerThenCb,
rejected: registerCatchCb,
settled: registerFinallyCb,
cleanup: registerCleanupCb,
};
}, []);
): void {
const init = useMemo<AsyncEffectInit<ResultType>>(() => {
return initFactory();
}, [deps]);

const isMounted = useIsMounted();
useEffect(() => {
const context: any = {};
const controller = new AbortController();

handler(context)
init
.execute(controller.signal)
.then((value) => {
if (isMounted() && thenCallback.current) thenCallback.current(value, context);
if (isMounted() && !controller.signal.aborted) init.onFulfilled?.(value);
})
.catch((err) => {
if (isMounted() && catchCallback.current) catchCallback.current(err, context);
if (isMounted() && !controller.signal.aborted) init.onRejected?.(err);
})
.finally(() => {
if (isMounted() && finallyCallback.current) finallyCallback.current(context);
if (isMounted() && !controller.signal.aborted) init.onSettled?.();
});

if (cleanupCallback.current) return () => cleanupCallback.current(context);
return undefined;
}, deps);

return chain;
return () => {
controller.abort();
init.onCleanup?.();
};
}, [init]);
}
32 changes: 22 additions & 10 deletions src/hooks/use-dom-event/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface AddEventListenerFunction<
}

export type UseDomEventRemoveListenerFunction = () => void;
export type UseDomEventAddListenerFunction<T extends HTMLElement | Window | Document> = T extends HTMLElement
export type UseDomEventAddListenerFunction<T extends HTMLElement | Window | Document | null> = T extends HTMLElement
? AddEventListenerFunction<T, HTMLElementEventMap>
: T extends Window
? AddEventListenerFunction<T, WindowEventMap>
Expand All @@ -40,13 +40,19 @@ export type UseDomEventAddListenerFunction<T extends HTMLElement | Window | Docu

/**
* Creates a React hook that registers DOM event listeners on the given
* `element`. The effect returns a `void` function that can be used to remove
* `target`. The effect returns a `void` function that can be used to remove
* the event listener manually. Event listeners created this way are
* automatically cleaned up before the component unmounts.
*/
export function useDomEvent<T extends HTMLElement | Window | Document | null>(
element: T | MutableRefObject<T> | RefObject<T>,
) {
taget: T | MutableRefObject<T> | RefObject<T>,
): UseDomEventAddListenerFunction<T>;
export function useDomEvent<T extends HTMLElement | Window | Document | null>(
taget: () => T | MutableRefObject<T> | RefObject<T>,
): UseDomEventAddListenerFunction<T>;
export function useDomEvent<T extends HTMLElement | Window | Document | null>(
target: T | (() => T) | MutableRefObject<T> | RefObject<T>,
): UseDomEventAddListenerFunction<T> {
return ((...eventListenerParams: any[]) => {
const [eventName, listener, depsOrOptions, optionsOrDeps] = eventListenerParams;

Expand All @@ -68,34 +74,40 @@ export function useDomEvent<T extends HTMLElement | Window | Document | null>(
}, [options]);

useEffect(() => {
const element = typeof target === 'function' ? target() : target;

// Bail out early if `element` is null.
if (!element) return undefined;

// Handle events from an `element` given as a valid node.
if (isWindow(element) || isDocument(element) || isElement(element)) {
const options = typeof savedOptions.current === 'boolean' ? savedOptions.current : { ...savedOptions.current };
const listener = (e: any) => savedListener.current(e);
element.addEventListener(eventName, listener, savedOptions.current);
element.addEventListener(eventName, listener, options);
removeListenerRef.current = () => {
element.removeEventListener(eventName, listener, savedOptions.current);
element.removeEventListener(eventName, listener, options);
};
return removeListenerRef.current;
}

// Handle events from an `element` given as a React ref.
if (isRefObject<T>(element)) {
if (!!element.current && isElement(element.current)) {
const el = element.current;
const options =
typeof savedOptions.current === 'boolean' ? savedOptions.current : { ...savedOptions.current };
const listener = (e: any) => savedListener.current(e);
element.current.addEventListener(eventName, listener, savedOptions.current);
el.addEventListener(eventName, listener, options);
removeListenerRef.current = () => {
element.current!.removeEventListener(eventName, listener, savedOptions.current);
el.removeEventListener(eventName, listener, options);
};
return removeListenerRef.current;
}
}

return undefined;
}, [eventName, element, ...deps]);
}, [eventName, typeof target !== 'function' && target, ...deps]);

return useCallback(() => removeListenerRef.current(), []);
}) as UseDomEventAddListenerFunction<NonNullable<T>>;
}) as UseDomEventAddListenerFunction<T>;
}