diff --git a/README.md b/README.md index b4f25b4..e967c3f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ npm install usable-react - [`useDeferredChildren`](./docs/use-deferred-children.md) - [`useDomEvent`](./docs/use-dom-event.md) - [`useEffectAfterMount`](./docs/use-effect-after-mount.md) +- [`useEffectOnce`](./docs/use-effect-once.md) - [`useEffectTrigger`](./docs/use-effect-trigger.md) - [`useFilter`](./docs/use-filter.md) - [`useForceUpdate`](./docs/use-force-update.md) diff --git a/docs/use-effect-after-mount.md b/docs/use-effect-after-mount.md index edd498b..f539837 100644 --- a/docs/use-effect-after-mount.md +++ b/docs/use-effect-after-mount.md @@ -1,6 +1,6 @@ # `useEffectAfterMount` -Same as React's native `useEffect`, except that it will skip the initial render. +Same as React's `useEffect`, except that it will skip the initial render. ## Example diff --git a/docs/use-effect-once.md b/docs/use-effect-once.md new file mode 100644 index 0000000..3b6fcfc --- /dev/null +++ b/docs/use-effect-once.md @@ -0,0 +1,11 @@ +# `useEffectOnce` + +Same as React's `useEffect`, except that it will only run on the the initial render (even in React >=v18). + +## Example + +```tsx +useEffectOnce(() => { + console.log('doot doot doot!') +}); +``` diff --git a/docs/use-effect-trigger.md b/docs/use-effect-trigger.md index 019511e..0bbb51f 100644 --- a/docs/use-effect-trigger.md +++ b/docs/use-effect-trigger.md @@ -1,6 +1,6 @@ # `useEffectTrigger` -Registers a side effect with the same signature as React's native `useEffect`, but the effect will only execute when the manual trigger function returned by this hook is invoked. +Registers a side effect with the same signature as React's `useEffect`, but the effect will only execute when the manual trigger function returned by this hook is invoked. A few use-cases for this hook include: diff --git a/src/hooks/use-debounced/index.ts b/src/hooks/use-debounced/index.ts index 1e27758..d50f95e 100644 --- a/src/hooks/use-debounced/index.ts +++ b/src/hooks/use-debounced/index.ts @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; /** - * Debounces the given value. If `delay` is zero, the value is updated - * synchronously. + * Debounces the given value. + * If `delay` is zero, the value is updated synchronously. */ export function useDebounced(value: T, delay = 300) { const isSynchronous = delay === 0; diff --git a/src/hooks/use-effect-once/index.ts b/src/hooks/use-effect-once/index.ts new file mode 100644 index 0000000..7161e3e --- /dev/null +++ b/src/hooks/use-effect-once/index.ts @@ -0,0 +1,38 @@ +import { EffectCallback, useEffect, useRef } from 'react'; +import { useForceUpdate } from '../use-force-update'; + +/** + * Exactly like `useEffect`, except that the effect only once + * (even in React >=v18 strict mode). + * + * Based on this workaround from AG Grid: + * https://blog.ag-grid.com/avoiding-react-18-double-mount/ + */ +export function useEffectOnce(effect: EffectCallback) { + const destroyFunc = useRef void)>(); + const effectCalled = useRef(false); + const renderAfterCalled = useRef(false); + const forceUpdate = useForceUpdate(); + + if (effectCalled.current) { + renderAfterCalled.current = true; + } + + useEffect(() => { + // Only execute the effect first time around... + if (!effectCalled.current) { + destroyFunc.current = effect(); + effectCalled.current = true; + } + + // Force one render after the effect is run... + forceUpdate(); + + return () => { + // If the comp didn't render since the useEffect + // was called, we know it's the dummy React cycle + if (!renderAfterCalled.current) return; + destroyFunc.current?.(); + }; + }, []); +} diff --git a/src/hooks/use-initial-render/index.ts b/src/hooks/use-initial-render/index.ts index 9bfa4df..d8111cd 100644 --- a/src/hooks/use-initial-render/index.ts +++ b/src/hooks/use-initial-render/index.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { useEffectOnce } from '../use-effect-once'; /** * Returns a `boolean` indicating whether the current update is the intial @@ -6,8 +7,8 @@ import { useEffect, useRef } from 'react'; */ export function useInitialRender() { const isInitialRender = useRef(true); - useEffect(() => { + useEffectOnce(() => { isInitialRender.current = false; - }, []); + }); return isInitialRender.current; } diff --git a/src/hooks/use-is-mounted/index.ts b/src/hooks/use-is-mounted/index.ts index 9d73c6f..ce85251 100644 --- a/src/hooks/use-is-mounted/index.ts +++ b/src/hooks/use-is-mounted/index.ts @@ -1,4 +1,5 @@ import { useRef, useEffect, useCallback } from 'react'; +import { useEffectOnce } from '../use-effect-once'; /** * Returns a memoized callback that when invoked returns `boolean` indicating if @@ -7,13 +8,13 @@ import { useRef, useEffect, useCallback } from 'react'; export function useIsMounted() { const isMountedRef = useRef(false); - useEffect(() => { + useEffectOnce(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; - }, []); + }); return useCallback(() => isMountedRef.current, []); } diff --git a/src/index.ts b/src/index.ts index b9bb1b1..b56b5a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from './hooks/use-debounced-effect'; export * from './hooks/use-deferred-children'; export * from './hooks/use-dom-event'; export * from './hooks/use-effect-after-mount'; +export * from './hooks/use-effect-once'; export * from './hooks/use-effect-trigger'; export * from './hooks/use-filter'; export * from './hooks/use-force-update';