Skip to content

Commit 420b787

Browse files
committed
main 🧊 add debounce throttle effect, add use element visibility
1 parent b10f6f2 commit 420b787

File tree

20 files changed

+964
-11
lines changed

20 files changed

+964
-11
lines changed

‎packages/core/src/bundle/hooks/sensors.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './useDevicePixelRatio/useDevicePixelRatio';
55
export * from './useDocumentEvent/useDocumentEvent';
66
export * from './useDocumentVisibility/useDocumentVisibility';
77
export * from './useElementSize/useElementSize';
8+
export * from './useElementVisibility/useElementVisibility';
89
export * from './useEventListener/useEventListener';
910
export * from './useHotkeys/useHotkeys';
1011
export * from './useIdle/useIdle';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useEffect, useRef } from 'react';
2+
/**
3+
* @name useDebounceEffect
4+
* @description – Hook that runs an effect after a delay when dependencies change
5+
* @category Utilities
6+
* @usage high
7+
*
8+
* @param {EffectCallback} effect The effect callback to run
9+
* @param {number} delay The delay in milliseconds
10+
* @param {DependencyList} deps The dependencies list for the effect
11+
*
12+
* @example
13+
* useDebounceEffect(() => console.log('effect'), 500, [value]);
14+
*/
15+
export const useDebounceEffect = (effect, delay, deps) => {
16+
const mountedRef = useRef(true);
17+
const timeoutRef = useRef(null);
18+
const cleanupRef = useRef(undefined);
19+
const effectRef = useRef(effect);
20+
const delayRef = useRef(delay);
21+
effectRef.current = effect;
22+
delayRef.current = delay;
23+
useEffect(() => {
24+
if (mountedRef.current) {
25+
mountedRef.current = false;
26+
return;
27+
}
28+
if (timeoutRef.current) {
29+
clearTimeout(timeoutRef.current);
30+
timeoutRef.current = null;
31+
}
32+
timeoutRef.current = setTimeout(() => {
33+
cleanupRef.current = effectRef.current();
34+
}, delayRef.current);
35+
return () => {
36+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
37+
timeoutRef.current = null;
38+
if (typeof cleanupRef.current === 'function') cleanupRef.current();
39+
};
40+
}, deps);
41+
};

‎packages/core/src/bundle/hooks/useDidUpdate/useDidUpdate.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,18 @@ import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect/useIsomo
1313
* useDidUpdate(() => console.log("effect runs on updates"), deps);
1414
*/
1515
export const useDidUpdate = (effect, deps) => {
16-
const mounted = useRef(false);
16+
const mountedRef = useRef(false);
1717
useIsomorphicLayoutEffect(
1818
() => () => {
19-
mounted.current = false;
19+
mountedRef.current = false;
2020
},
2121
[]
2222
);
2323
useIsomorphicLayoutEffect(() => {
24-
if (mounted.current) {
24+
if (mountedRef.current) {
2525
return effect();
2626
}
27-
mounted.current = true;
27+
mountedRef.current = true;
2828
return undefined;
2929
}, deps);
3030
};
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { isTarget } from '@/utils/helpers';
3+
import { useRefState } from '../useRefState/useRefState';
4+
/**
5+
* @name useElementVisibility
6+
* @description – Hook that tracks element visibility using IntersectionObserver
7+
* @category Sensors
8+
* @usage medium
9+
*
10+
* @browserapi IntersectionObserver https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
11+
*
12+
* @overload
13+
* @template Target The target element type
14+
* @param {UseElementVisibilityOptions} [options] The IntersectionObserver options
15+
* @returns {UseElementVisibilityReturn & { ref: StateRef<Target> }} An object containing the visibility state and a ref to attach to the target element
16+
*
17+
* @example
18+
* const { ref, entry, inView } = useElementVisibility();
19+
*
20+
* @overload
21+
* @param {HookTarget} target The target element to detect visibility
22+
* @param {UseElementVisibilityOptions} [options] The IntersectionObserver options
23+
* @returns {UseElementVisibilityReturn} An object containing the visibility state
24+
*
25+
* @example
26+
* const { entry, inView } = useElementVisibility(ref);
27+
*
28+
* @overload
29+
* @template Target The target element type
30+
* @param {((entry: IntersectionObserverEntry, observer: IntersectionObserver) => void)} callback The callback to execute when visibility changes
31+
* @returns {UseElementVisibilityReturn & { ref: StateRef<Target> }} An object containing the visibility state and a ref to attach to the target element
32+
*
33+
* @example
34+
* const { ref, entry, inView } = useElementVisibility((entry) => console.log('visible:', entry.isIntersecting));
35+
*
36+
* @overload
37+
* @param {HookTarget} target The target element to detect visibility
38+
* @param {((entry: IntersectionObserverEntry, observer: IntersectionObserver) => void)} callback The callback to execute when visibility changes
39+
* @returns {UseElementVisibilityReturn} An object containing the visibility state
40+
*
41+
* @example
42+
* const { entry, inView } = useElementVisibility(ref, (entry) => console.log('visible:', entry.isIntersecting));
43+
*/
44+
export const useElementVisibility = (...params) => {
45+
const target = isTarget(params[0]) ? params[0] : undefined;
46+
const options = target
47+
? typeof params[1] === 'object'
48+
? params[1]
49+
: { onChange: params[1] }
50+
: typeof params[0] === 'object'
51+
? params[0]
52+
: { onChange: params[0] };
53+
const callback = options?.onChange;
54+
const enabled = options?.enabled ?? true;
55+
const [entry, setEntry] = useState();
56+
const inView = entry?.isIntersecting ?? false;
57+
const internalRef = useRefState();
58+
const internalCallbackRef = useRef(callback);
59+
internalCallbackRef.current = callback;
60+
useEffect(() => {
61+
if (!enabled || (!target && !internalRef.state)) return;
62+
const element = target ? isTarget.getElement(target) : internalRef.current;
63+
if (!element) return;
64+
const observer = new IntersectionObserver(
65+
(entries) => {
66+
const firstEntry = entries[0];
67+
if (firstEntry) {
68+
setEntry(firstEntry);
69+
internalCallbackRef.current?.(firstEntry, observer);
70+
}
71+
},
72+
{
73+
...options,
74+
root: options?.root ? isTarget.getElement(options.root) : document
75+
}
76+
);
77+
observer.observe(element);
78+
return () => {
79+
observer.disconnect();
80+
};
81+
}, [
82+
target && isTarget.getRawElement(target),
83+
internalRef.state,
84+
options?.rootMargin,
85+
options?.threshold,
86+
options?.root,
87+
enabled
88+
]);
89+
if (target) return { entry, inView };
90+
return {
91+
entry,
92+
inView,
93+
ref: internalRef
94+
};
95+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useEffect, useRef } from 'react';
2+
/**
3+
* @name useThrottleEffect
4+
* @description – Hook that runs an effect at most once per delay period when dependencies change
5+
* @category Utilities
6+
* @usage medium
7+
*
8+
* @param {EffectCallback} effect The effect callback to run
9+
* @param {number} delay The delay in milliseconds
10+
* @param {DependencyList} deps The dependencies list for the effect
11+
*
12+
* @example
13+
* useThrottleEffect(() => console.log('effect'), 500, [value]);
14+
*/
15+
export const useThrottleEffect = (effect, delay, deps) => {
16+
const mountedRef = useRef(true);
17+
const cleanupRef = useRef(undefined);
18+
const timeoutRef = useRef(null);
19+
const isCalledRef = useRef(false);
20+
const effectRef = useRef(effect);
21+
const delayRef = useRef(delay);
22+
effectRef.current = effect;
23+
delayRef.current = delay;
24+
useEffect(() => {
25+
if (mountedRef.current) {
26+
mountedRef.current = false;
27+
return;
28+
}
29+
if (isCalledRef.current) return;
30+
cleanupRef.current = effectRef.current();
31+
isCalledRef.current = true;
32+
setTimeout(() => {
33+
isCalledRef.current = false;
34+
timeoutRef.current = setTimeout(() => {
35+
cleanupRef.current = effectRef.current();
36+
}, delayRef.current);
37+
}, delayRef.current);
38+
return () => {
39+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
40+
timeoutRef.current = null;
41+
if (typeof cleanupRef.current === 'function') cleanupRef.current();
42+
};
43+
}, deps);
44+
};

‎packages/core/src/bundle/hooks/utilities.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ export * from './useBatchedCallback/useBatchedCallback';
33
export * from './useConst/useConst';
44
// timing
55
export * from './useDebounceCallback/useDebounceCallback';
6+
export * from './useDebounceEffect/useDebounceEffect';
67
export * from './useDebounceState/useDebounceState';
78
export * from './useDebounceValue/useDebounceValue';
89
export * from './useEvent/useEvent';
910
export * from './useLastChanged/useLastChanged';
1011
export * from './useLatest/useLatest';
1112
export * from './usePrevious/usePrevious';
1213
export * from './useThrottleCallback/useThrottleCallback';
14+
export * from './useThrottleEffect/useThrottleEffect';
1315
export * from './useThrottleState/useThrottleState';
1416
export * from './useThrottleValue/useThrottleValue';

‎packages/core/src/hooks/sensors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './useDevicePixelRatio/useDevicePixelRatio';
55
export * from './useDocumentEvent/useDocumentEvent';
66
export * from './useDocumentVisibility/useDocumentVisibility';
77
export * from './useElementSize/useElementSize';
8+
export * from './useElementVisibility/useElementVisibility';
89
export * from './useEventListener/useEventListener';
910
export * from './useHotkeys/useHotkeys';
1011
export * from './useIdle/useIdle';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useCounter, useDebounceEffect } from '@siberiacancode/reactuse';
2+
3+
const Demo = () => {
4+
const counter = useCounter();
5+
const debounceCounter = useCounter();
6+
const effectCounter = useCounter();
7+
8+
useDebounceEffect(
9+
() => {
10+
debounceCounter.inc();
11+
effectCounter.inc();
12+
},
13+
500,
14+
[counter.value]
15+
);
16+
17+
return (
18+
<>
19+
<div className='flex flex-col gap-1'>
20+
<div className='text-sm'>
21+
Current count: <code>{counter.value}</code>
22+
</div>
23+
<div className='text-sm'>
24+
Debounced count: <code>{debounceCounter.value}</code>
25+
</div>
26+
<div className='text-sm'>
27+
Effect runs: <code>{effectCounter.value}</code>
28+
</div>
29+
</div>
30+
31+
<div className='mt-4 flex flex-wrap'>
32+
<button type='button' onClick={() => counter.inc()}>
33+
Increment
34+
</button>
35+
<button type='button' onClick={() => counter.dec()}>
36+
Decrement
37+
</button>
38+
</div>
39+
40+
<div className='mt-4 text-xs'>Click buttons rapidly to see the debounce effect.</div>
41+
</>
42+
);
43+
};
44+
45+
export default Demo;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { vi } from 'vitest';
3+
4+
import { renderHookServer } from '@/tests';
5+
6+
import { useDebounceEffect } from './useDebounceEffect';
7+
8+
beforeEach(vi.useFakeTimers);
9+
10+
afterEach(() => {
11+
vi.useRealTimers();
12+
vi.restoreAllMocks();
13+
});
14+
15+
it('Should use debounce effect', () => {
16+
const effect = vi.fn();
17+
renderHook(() => useDebounceEffect(effect, 100, []));
18+
19+
act(() => vi.advanceTimersByTime(100));
20+
21+
expect(effect).not.toHaveBeenCalled();
22+
});
23+
24+
it('Should use debounce effect on server side', () => {
25+
const effect = vi.fn();
26+
renderHookServer(() => useDebounceEffect(effect, 100, []));
27+
28+
expect(effect).not.toHaveBeenCalled();
29+
});
30+
31+
it('Should debounce effect execution', () => {
32+
const effect = vi.fn();
33+
34+
const { rerender } = renderHook(({ value }) => useDebounceEffect(effect, 100, [value]), {
35+
initialProps: { value: 'initial value' }
36+
});
37+
38+
expect(effect).not.toHaveBeenCalled();
39+
40+
rerender({ value: 'new value' });
41+
42+
act(() => vi.advanceTimersByTime(100));
43+
44+
expect(effect).toHaveBeenCalledOnce();
45+
});
46+
47+
it('Should cleanup on unmount', () => {
48+
const cleanup = vi.fn();
49+
const effect = vi.fn(() => cleanup);
50+
51+
const { unmount, rerender } = renderHook((value) => useDebounceEffect(effect, 100, [value]), {
52+
initialProps: 'initial value'
53+
});
54+
55+
expect(effect).not.toHaveBeenCalled();
56+
57+
rerender('new value');
58+
59+
act(() => vi.advanceTimersByTime(100));
60+
61+
unmount();
62+
63+
expect(cleanup).toHaveBeenCalledOnce();
64+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { DependencyList, EffectCallback } from 'react';
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
/**
6+
* @name useDebounceEffect
7+
* @description – Hook that runs an effect after a delay when dependencies change
8+
* @category Utilities
9+
* @usage high
10+
*
11+
* @param {EffectCallback} effect The effect callback to run
12+
* @param {number} delay The delay in milliseconds
13+
* @param {DependencyList} deps The dependencies list for the effect
14+
*
15+
* @example
16+
* useDebounceEffect(() => console.log('effect'), 500, [value]);
17+
*/
18+
export const useDebounceEffect = (effect: EffectCallback, delay: number, deps?: DependencyList) => {
19+
const mountedRef = useRef(true);
20+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21+
const cleanupRef = useRef<ReturnType<EffectCallback>>(undefined);
22+
const effectRef = useRef(effect);
23+
const delayRef = useRef(delay);
24+
25+
effectRef.current = effect;
26+
delayRef.current = delay;
27+
28+
useEffect(() => {
29+
if (mountedRef.current) {
30+
mountedRef.current = false;
31+
return;
32+
}
33+
34+
if (timeoutRef.current) {
35+
clearTimeout(timeoutRef.current);
36+
timeoutRef.current = null;
37+
}
38+
39+
timeoutRef.current = setTimeout(() => {
40+
cleanupRef.current = effectRef.current();
41+
}, delayRef.current);
42+
43+
return () => {
44+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
45+
timeoutRef.current = null;
46+
if (typeof cleanupRef.current === 'function') cleanupRef.current();
47+
};
48+
}, deps);
49+
};

0 commit comments

Comments
 (0)