Skip to content

Commit

Permalink
feat: new hook useCustomCompareEffect
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed Sep 28, 2021
1 parent 1c53a68 commit 036502c
Show file tree
Hide file tree
Showing 10 changed files with 597 additions and 326 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ our [migration guide](https://react-hookz.github.io/web/?path=/docs/migrating-fr

- [**`useConditionalEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect--example)
— Like `useEffect` but callback invoked only if conditions match predicate.
- [**`useConditionalUpdateEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect--example)
— Like `useUpdateEffect` but callback invoked only if conditions match predicate.
- [**`useCustomCompareEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usecustomcompareeffect--example)
— Like `useEffect` but uses provided comparator function to validate dependencies change.
- [**`useDebouncedEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usedebouncedeffect--example)
— Like `useEffect`, but passed function is debounced.
- [**`useFirstMountState`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usefirstmountstate--example)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
IConditionsPredicate,
IConditionsList,
} from './useConditionalEffect/useConditionalEffect';
export { useCustomCompareEffect } from './useCustomCompareEffect/useCustomCompareEffect';
export { useDebouncedEffect } from './useDebouncedEffect/useDebouncedEffect';
export { useFirstMountState } from './useFirstMountState/useFirstMountState';
export { useIsMounted } from './useIsMounted/useIsMounted';
Expand Down
8 changes: 8 additions & 0 deletions src/useCustomCompareEffect/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as React from 'react';
import { useCustomCompareEffect } from '../..';

export const Example: React.FC = () => {
useCustomCompareEffect(() => {}, []);

return <div>Im unable to imagine worthy example 😭😭</div>;
};
47 changes: 47 additions & 0 deletions src/useCustomCompareEffect/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="Lifecycle/useCustomCompareEffect" component={Example} />

# useCustomCompareEffect

Like `useEffect` but uses provided comparator function to validate dependencies change.

- SSR-friendly, meaning that comparator won't be called on the server.
- Ability to change underlying effect hook (default to `useEffect`).

#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useCustomCompareEffect<
Callback extends IEffectCallback = IEffectCallback,
Deps extends DependencyList = DependencyList,
HookRestArgs extends any[] = any[],
R extends HookRestArgs = HookRestArgs
>(
callback: Callback,
deps: Deps,
comparator: IDependenciesComparator<Deps> = basicDepsComparator,
effectHook: IEffectHook<Callback, Deps, HookRestArgs> = useEffect,
...effectHookRestArgs: R
): void;
```

#### Arguments

- **callback** _`IEffectCallback`_ - Function that will be passed to underlying effect hook.
- **deps** _`React.DependencyList`_ - Dependencies list like for `useEffect`. If not undefined -
effect will be triggered when deps changed AND conditions are satisfying predicate.
- **comparator** _`IDependenciesComparator<Deps> `_ - Function that compares two dependencies
arrays, and returns true in case they're equal.
- **effectHook** _`IEffectHook<Callback, Deps, HookRestArgs>`_ - Effect hook that will be used to
run callback. Must comply `useEffect` signature, meaning that callback should be placed as first
argument and dependencies list as second.
- **...effectHookRestArgs** _`HookRestArgs`_ - Extra arguments that passed to effectHook. Meaning
the arguments that are after 2nd argument.
66 changes: 66 additions & 0 deletions src/useCustomCompareEffect/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { DependencyList } from 'react';
import { IEffectCallback, useCustomCompareEffect, useUpdateEffect } from '../..';

describe('useCustomCompareEffect', () => {
it('should be defined', () => {
expect(useCustomCompareEffect).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useCustomCompareEffect(() => {}, []));
expect(result.error).toBeUndefined();
});

it('should not call provided comparator on render', () => {
const spy = jest.fn();
const { rerender } = renderHook(() =>
useCustomCompareEffect(() => {}, [], spy, useUpdateEffect)
);
expect(spy).toHaveBeenCalledTimes(0);
});

it('should call comparator with previous and current deps as args', () => {
const spy = jest.fn();
const { rerender } = renderHook(
({ deps }) => useCustomCompareEffect(() => {}, deps, spy, useUpdateEffect),
{ initialProps: { deps: [1, 2] } }
);
rerender({ deps: [1, 3] });

expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toStrictEqual([1, 2]);
expect(spy.mock.calls[0][1]).toStrictEqual([1, 3]);
});

it('should not pass new deps to underlying effect only if comparator reported unequal deps', () => {
const spy = jest.fn(useUpdateEffect);
const { rerender } = renderHook(
({ deps }) => useCustomCompareEffect(() => {}, deps, undefined, spy),
{ initialProps: { deps: [1, 2] } }
);
rerender({ deps: [1, 2] });

expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[0][1]).toStrictEqual([1, 2]);
expect(spy.mock.calls[0][1]).toBe(spy.mock.calls[1][1]);

rerender({ deps: [1, 3] });

expect(spy).toHaveBeenCalledTimes(3);
expect(spy.mock.calls[2][1]).toStrictEqual([1, 3]);
expect(spy.mock.calls[0][1]).not.toBe(spy.mock.calls[2][1]);
});

it('should pass res argument to underlying hook', () => {
const spy = jest.fn((c: IEffectCallback, d: DependencyList, _n: number) =>
useUpdateEffect(c, d)
);
const { rerender } = renderHook(
({ deps }) => useCustomCompareEffect(() => {}, deps, undefined, spy, 123),
{ initialProps: { deps: [1, 2] } }
);

expect(spy.mock.calls[0][2]).toBe(123);
});
});
19 changes: 19 additions & 0 deletions src/useCustomCompareEffect/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useCustomCompareEffect } from '../..';

describe('useCustomCompareEffect', () => {
it('should be defined', () => {
expect(useCustomCompareEffect).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useCustomCompareEffect(() => {}, []));
expect(result.error).toBeUndefined();
});

it('should not invoke comparator', () => {
const spy = jest.fn();
renderHook(() => useCustomCompareEffect(() => {}, [], spy));
expect(spy).not.toHaveBeenCalled();
});
});
54 changes: 54 additions & 0 deletions src/useCustomCompareEffect/useCustomCompareEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DependencyList, useEffect, useRef } from 'react';
import { isBrowser } from '../util/const';
import { basicDepsComparator } from '../util/misc';

export type IDependenciesComparator<Deps extends DependencyList = DependencyList> = (
a: Deps,
b: Deps
) => boolean;

export type IEffectCallback = (...args: any[]) => any;

export type IEffectHook<
Callback extends IEffectCallback = IEffectCallback,
Deps extends DependencyList = DependencyList,
RestArgs extends any[] = any[]
> = ((...args: [Callback, Deps, ...RestArgs]) => void) | ((...args: [Callback, Deps]) => void);

/**
* Like `useEffect` but uses provided comparator function to validate dependencies change.
*
* @param callback Function that will be passed to underlying effect hook.
* @param deps Dependencies list, like for `useEffect` hook.
* @param comparator Function that compares two dependencies arrays, and returns true in case
* they're equal.
* @param effectHook Effect hook that will be used to run callback. Must comply `useEffect`
* signature, meaning that callback should be placed as first argument and dependencies list
* as second.
* @param effectHookRestArgs Extra arguments that passed to effectHook.
*/
export function useCustomCompareEffect<
Callback extends IEffectCallback = IEffectCallback,
Deps extends DependencyList = DependencyList,
HookRestArgs extends any[] = any[],
R extends HookRestArgs = HookRestArgs
>(
callback: Callback,
deps: Deps,
comparator: IDependenciesComparator<Deps> = basicDepsComparator,
effectHook: IEffectHook<Callback, Deps, HookRestArgs> = useEffect,
...effectHookRestArgs: R
): void {
const dependencies = useRef<Deps>();

// Effects not working in SSR environment therefore no sense to invoke comparator
if (
dependencies.current === undefined ||
(isBrowser && !comparator(dependencies.current, deps))
) {
dependencies.current = deps;
}

effectHook(callback, dependencies.current, ...effectHookRestArgs);
}
21 changes: 20 additions & 1 deletion src/util/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { resolveHookState } from '../resolveHookState';
import { off, on } from '../misc';
import { basicDepsComparator, off, on } from '../misc';

describe('resolveHookState', () => {
it('it should be defined', () => {
Expand Down Expand Up @@ -65,4 +65,23 @@ describe('misc', () => {
}).not.toThrow();
});
});

describe('basicDepsComparator', () => {
it('should return true if both arrays ref-equal', () => {
const d1 = [1, 2, 3];
expect(basicDepsComparator(d1, d1)).toBe(true);
});

it('should return false in case array has different length', () => {
expect(basicDepsComparator([1], [1, 2])).toBe(false);
});

it('should return false in respective elements not equal', () => {
expect(basicDepsComparator([1, 2, 3], [1, 3, 2])).toBe(false);
});

it('should return true in case arrays are equal', () => {
expect(basicDepsComparator([1, 2, 3], [1, 2, 3])).toBe(true);
});
});
});
16 changes: 16 additions & 0 deletions src/util/misc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DependencyList } from 'react';

import { IDependenciesComparator } from '../useCustomCompareEffect/useCustomCompareEffect';

export function on<T extends EventTarget>(
obj: T | null,
...args:
Expand Down Expand Up @@ -38,6 +40,20 @@ export const hasOwnProperty = <
export const yieldTrue = () => true as const;
export const yieldFalse = () => false as const;

export const basicDepsComparator: IDependenciesComparator = (d1, d2) => {
if (d1 === d2) return true;

if (d1.length !== d2.length) return false;

for (let i = 0; i < d1.length; i++) {
if (d1[i] !== d2[i]) {
return false;
}
}

return true;
};

export type IEffectCallback = (...args: any[]) => any;

export type IEffectHook<
Expand Down
Loading

0 comments on commit 036502c

Please sign in to comment.