Skip to content

Commit e45c5a7

Browse files
feat(hooks): add useConditionalEffect hook (#242)
Co-authored-by: seungrodotlee <[email protected]>
1 parent e94884a commit e45c5a7

File tree

4 files changed

+220
-0
lines changed

4 files changed

+220
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useConditionalEffect } from './useConditionalEffect.ts';
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { act } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx';
5+
6+
import { useConditionalEffect } from './useConditionalEffect.ts';
7+
8+
describe('useConditionalEffect', () => {
9+
it('is safe on server side rendering', () => {
10+
const effect = vi.fn();
11+
const condition = vi.fn(() => false);
12+
13+
renderHookSSR.serverOnly(() => useConditionalEffect(effect, [1], condition));
14+
15+
expect(condition).toHaveBeenCalled();
16+
expect(effect).not.toHaveBeenCalled();
17+
});
18+
19+
it('should not run effect when condition returns false', async () => {
20+
const effect = vi.fn();
21+
const condition = vi.fn(() => false);
22+
23+
await renderHookSSR(() => useConditionalEffect(effect, [1], condition));
24+
25+
expect(condition).toHaveBeenCalledWith(undefined, [1]);
26+
expect(effect).not.toHaveBeenCalled();
27+
});
28+
29+
it('should run effect when condition returns true', async () => {
30+
const effect = vi.fn();
31+
const condition = vi.fn(() => true);
32+
33+
await renderHookSSR(() => useConditionalEffect(effect, [1], condition));
34+
35+
expect(condition).toHaveBeenCalledWith(undefined, [1]);
36+
expect(effect).toHaveBeenCalled();
37+
});
38+
39+
it('should warn when an empty dependency array is provided', async () => {
40+
const originalWarn = console.warn;
41+
console.warn = vi.fn();
42+
43+
const effect = vi.fn();
44+
const condition = vi.fn(() => true);
45+
46+
await renderHookSSR(() => useConditionalEffect(effect, [], condition));
47+
48+
expect(console.warn).toHaveBeenCalledWith(
49+
'useConditionalEffect received an empty dependency array. ' +
50+
'This may indicate missing dependencies and could lead to unexpected behavior.'
51+
);
52+
53+
console.warn = originalWarn;
54+
});
55+
56+
it('should run effect multiple times when condition is repeatedly true', async () => {
57+
const effect = vi.fn();
58+
const condition = vi.fn(() => true);
59+
60+
const { rerender } = await renderHookSSR(({ deps }) => useConditionalEffect(effect, deps, condition), {
61+
initialProps: { deps: [1] },
62+
});
63+
64+
expect(effect).toHaveBeenCalledTimes(1);
65+
66+
effect.mockClear();
67+
await act(async () => {
68+
rerender({ deps: [2] });
69+
});
70+
71+
expect(condition).toHaveBeenCalledWith([1], [2]);
72+
expect(effect).toHaveBeenCalledTimes(1);
73+
74+
effect.mockClear();
75+
await act(async () => {
76+
rerender({ deps: [3] });
77+
});
78+
79+
expect(condition).toHaveBeenCalledWith([2], [3]);
80+
expect(effect).toHaveBeenCalledTimes(1);
81+
});
82+
83+
it('should run cleanup function when effect returns one', async () => {
84+
const cleanup = vi.fn();
85+
const effect = vi.fn(() => cleanup);
86+
const condition = vi.fn(() => true);
87+
88+
const { unmount } = await renderHookSSR(() => useConditionalEffect(effect, [1], condition));
89+
90+
unmount();
91+
92+
expect(cleanup).toHaveBeenCalled();
93+
});
94+
95+
it('should store deps for next comparison', async () => {
96+
const effect = vi.fn();
97+
const condition = vi.fn(() => false);
98+
99+
const { rerender } = await renderHookSSR(({ deps }) => useConditionalEffect(effect, deps, condition), {
100+
initialProps: { deps: [1] },
101+
});
102+
103+
expect(condition).toHaveBeenCalledWith(undefined, [1]);
104+
105+
condition.mockClear();
106+
107+
await act(async () => {
108+
rerender({ deps: [2] });
109+
});
110+
111+
expect(condition).toHaveBeenCalledWith([1], [2]);
112+
});
113+
114+
it('should run effect based on conditional logic', async () => {
115+
const effect = vi.fn();
116+
117+
const condition = vi.fn((prev: readonly number[] | undefined, current: readonly number[]) => {
118+
if (prev === undefined) return false;
119+
return current[0] > prev[0];
120+
});
121+
122+
const { rerender } = await renderHookSSR(({ count }) => useConditionalEffect(effect, [count], condition), {
123+
initialProps: { count: 0 },
124+
});
125+
126+
expect(effect).not.toHaveBeenCalled();
127+
128+
effect.mockClear();
129+
condition.mockClear();
130+
131+
await act(async () => {
132+
rerender({ count: 1 });
133+
});
134+
135+
expect(condition).toHaveBeenCalledWith([0], [1]);
136+
expect(effect).toHaveBeenCalled();
137+
138+
effect.mockClear();
139+
condition.mockClear();
140+
141+
await act(async () => {
142+
rerender({ count: 0 });
143+
});
144+
145+
expect(condition).toHaveBeenCalledWith([1], [0]);
146+
expect(effect).not.toHaveBeenCalled();
147+
});
148+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { type DependencyList, type EffectCallback, useCallback, useEffect, useRef } from 'react';
2+
3+
/**
4+
* @description
5+
* `useConditionalEffect` is a React hook that conditionally executes effects based on a predicate function.
6+
* This provides more control over when effects run beyond just dependency changes.
7+
*
8+
* @param {EffectCallback} effect - The effect callback to run.
9+
* @param {DependencyList} deps - Dependencies array, similar to useEffect.
10+
* @param {(prevDeps: T | undefined, currentDeps: T) => boolean} condition - Function that determines if the effect should run based on previous and current deps.
11+
* - On the initial render, `prevDeps` will be `undefined`. Your `condition` function should handle this case.
12+
* - If you want your effect to run on the initial render, return `true` when `prevDeps` is `undefined`.
13+
* - If you don't want your effect to run on the initial render, return `false` when `prevDeps` is `undefined`.
14+
*
15+
* @example
16+
* import { useConditionalEffect } from 'react-simplikit';
17+
*
18+
* function Component() {
19+
* const [count, setCount] = useState(0);
20+
*
21+
* // Only run effect when count increases
22+
* useConditionalEffect(
23+
* () => {
24+
* console.log(`Count increased to ${count}`);
25+
* },
26+
* [count],
27+
* (prevDeps, currentDeps) => {
28+
* // Only run when count is defined and has increased
29+
* return prevDeps && currentDeps[0] > prevDeps[0];
30+
* }
31+
* );
32+
*
33+
* return (
34+
* <button onClick={() => setCount(prev => prev + 1)}>
35+
* Increment: {count}
36+
* </button>
37+
* );
38+
* }
39+
*
40+
*/
41+
export function useConditionalEffect<T extends DependencyList>(
42+
effect: EffectCallback,
43+
deps: T,
44+
condition: (prevDeps: T | undefined, currentDeps: T) => boolean
45+
): void {
46+
const prevDepsRef = useRef<T | undefined>(undefined);
47+
// eslint-disable-next-line react-hooks/exhaustive-deps
48+
const memoizedCondition = useCallback(condition, deps);
49+
50+
if (deps.length === 0) {
51+
console.warn(
52+
'useConditionalEffect received an empty dependency array. ' +
53+
'This may indicate missing dependencies and could lead to unexpected behavior.'
54+
);
55+
}
56+
57+
const shouldRun = memoizedCondition(prevDepsRef.current, deps);
58+
59+
useEffect(() => {
60+
if (shouldRun) {
61+
const cleanup = effect();
62+
prevDepsRef.current = deps;
63+
return cleanup;
64+
}
65+
66+
prevDepsRef.current = deps;
67+
68+
// eslint-disable-next-line react-hooks/exhaustive-deps
69+
}, deps);
70+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { SwitchCase } from './components/SwitchCase/index.ts';
44
export { useAsyncEffect } from './hooks/useAsyncEffect/index.ts';
55
export { useBooleanState } from './hooks/useBooleanState/index.ts';
66
export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts';
7+
export { useConditionalEffect } from './hooks/useConditionalEffect/index.ts';
78
export { useControlledState } from './hooks/useControlledState/index.ts';
89
export { useCounter } from './hooks/useCounter/index.ts';
910
export { useDebounce } from './hooks/useDebounce/index.ts';

0 commit comments

Comments
 (0)