Skip to content

Commit

Permalink
feat: new hooks, useConditionalEffect and useConditionalUpdateEffect (#…
Browse files Browse the repository at this point in the history
…26)

feat: new hooks, useConditionalEffect and useConditionalUpdateEffect
  • Loading branch information
xobotyi authored Apr 28, 2021
1 parent 2c1834d commit eb7f0a5
Show file tree
Hide file tree
Showing 16 changed files with 426 additions and 16 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Run effect only when component is unmounted.
- [`useUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect)
— Effect hook that ignores the first render (not invoked on mount).
- [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect)
— Like `useEffect` but callback invoked only if conditions match predicate.
- [`useConditionalUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect)
— Like `useUpdateEffect` but callback invoked only if conditions match predicate.
- #### State
- [`useToggle`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usetoggle)
— Like `useState`, but can only become `true` or `false`.
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export { useToggle } from './useToggle';
export { useRerender } from './useRerender';
export { usePrevious } from './usePrevious';
export { useIsMounted } from './useIsMounted';
export { useConditionalEffect } from './useConditionalEffect';
export { useConditionalUpdateEffect } from './useConditionalUpdateEffect';
36 changes: 36 additions & 0 deletions src/useConditionalEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { EffectCallback, useEffect, useRef } from 'react';
import { noop, truthyArrayItemsPredicate } from './util/const';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type IUseConditionalEffectPredicate<Cond extends ReadonlyArray<any>> = (
conditions: Cond
) => boolean;

/**
* Like `useEffect` but callback invoked only if conditions match predicate.
*
* @param callback Callback to invoke
* @param conditions Conditions array
* @param predicate Predicate that defines whether conditions satisfying certain
* provision. By default, it is all-truthy provision, meaning that all
* conditions should be truthy.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useConditionalEffect<T extends ReadonlyArray<any>>(
callback: EffectCallback,
conditions: T,
predicate: IUseConditionalEffectPredicate<T> = truthyArrayItemsPredicate
): void {
const shouldInvoke = predicate(conditions);
// eslint-disable-next-line @typescript-eslint/ban-types
const deps = useRef<{}>();

// we want callback invocation only in case all conditions matches predicate
if (shouldInvoke) {
deps.current = {};
}

// we can't avoid on-mount invocations so slip noop callback for the cases we dont need invocation
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(shouldInvoke ? callback : noop, [deps.current]);
}
38 changes: 38 additions & 0 deletions src/useConditionalUpdateEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EffectCallback, useEffect, useRef } from 'react';
import { noop, truthyArrayItemsPredicate } from './util/const';
import { useFirstMountState } from './useFirstMountState';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type IUseConditionalUpdateEffectPredicate<Cond extends ReadonlyArray<any>> = (
conditions: Cond
) => boolean;

/**
* Like `useUpdateEffect` but callback invoked only if conditions match predicate.
*
* @param callback Callback to invoke
* @param conditions Conditions array
* @param predicate Predicate that defines whether conditions satisfying certain
* provision. By default, it is all-truthy provision, meaning that all
* conditions should be truthy.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useConditionalUpdateEffect<T extends ReadonlyArray<any>>(
callback: EffectCallback,
conditions: T,
predicate: IUseConditionalUpdateEffectPredicate<T> = truthyArrayItemsPredicate
): void {
const isFirstMount = useFirstMountState();
const shouldInvoke = !isFirstMount && predicate(conditions);
// eslint-disable-next-line @typescript-eslint/ban-types
const deps = useRef<{}>();

// we want callback invocation only in case all conditions matches predicate
if (shouldInvoke) {
deps.current = {};
}

// we can't avoid on-mount invocations so slip noop callback for the cases we dont need invocation
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(shouldInvoke ? callback : noop, [deps.current]);
}
10 changes: 3 additions & 7 deletions src/useUpdateEffect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DependencyList, EffectCallback, useEffect } from 'react';
import { useFirstMountState } from './useFirstMountState';
import { noop } from './util/const';

/**
* Effect hook that ignores the first render (not invoked on mount).
Expand All @@ -10,11 +11,6 @@ import { useFirstMountState } from './useFirstMountState';
export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList): void {
const isFirstMount = useFirstMountState();

// eslint-disable-next-line consistent-return
useEffect(() => {
if (!isFirstMount) {
return effect();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(isFirstMount ? noop : effect, deps);
}
5 changes: 5 additions & 0 deletions src/util/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ export const isBrowser =
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
typeof document !== 'undefined';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function truthyArrayItemsPredicate(conditions: ReadonlyArray<any>): boolean {
return conditions.every((i) => Boolean(i));
}
51 changes: 51 additions & 0 deletions stories/useConditionalEffect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as React from 'react';
import { useState } from 'react';
import { useConditionalEffect, useToggle } from '../src';

export const Example: React.FC = () => {
const [isToggled, toggle] = useToggle(false);

const ToggledComponent: React.FC = () => {
const [state1, setState1] = useState(2);
const [state2, setState2] = useState(2);

useConditionalEffect(
() => {
// eslint-disable-next-line no-alert
alert('COUNTERS VALUES ARE EVEN');
},
[state1, state2],
(conditions) => conditions.every((i) => i && i % 2 === 0)
);

return (
<div>
<div>Alert will be displayed when both counters values are even</div>
<div>Effect also invoked on initial mount</div>
<button
onClick={() => {
setState1((i) => i + 1);
}}>
increment counter 1 [{state1}]
</button>{' '}
<button
onClick={() => {
setState2((i) => i + 1);
}}>
increment counter 2 [{state2}]
</button>
</div>
);
};

if (isToggled) {
return <ToggledComponent />;
}

return (
<div>
<div>As example component displays alert right from mount - it is initially unmounted.</div>
<button onClick={() => toggle()}>Mount example component</button>
</div>
);
};
38 changes: 38 additions & 0 deletions stories/useConditionalEffect.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks";
import { Example } from "./useConditionalEffect.stories";

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

# useConditionalEffect

Like `useEffect` but callback invoked only if conditions match predicate. By default, predicate
matches if all conditions are truthy.

Like `useEffect`, it performs conditions match on first mount.

#### Example

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

## Reference

```ts
type IUseConditionalEffectPredicate<Cond extends ReadonlyArray<any>> = (
conditions: Cond
) => boolean;

function useConditionalEffect<T extends ReadonlyArray<any>>(
callback: React.EffectCallback,
conditions: T,
predicate?: IUseConditionalEffectPredicate<T>
): void;
```

#### Arguments

- _**callback**_ _`React.EffectCallback`_ - Effect callback like for `React.useEffect` hook
- _**conditions**_ _`ReadonlyArray<any>`_ - Conditions that are matched against predicate
- _**predicate**_ _`IUseConditionalEffectPredicate<ReadonlyArray<any>>`_ - Predicate that matches conditions.
By default, it is all-truthy provision, meaning that all conditions should be truthy.
36 changes: 36 additions & 0 deletions stories/useConditionalUpdateEffect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import { useState } from 'react';
import { useConditionalUpdateEffect } from '../src';

export const Example: React.FC = () => {
const [state1, setState1] = useState(2);
const [state2, setState2] = useState(2);

useConditionalUpdateEffect(
() => {
// eslint-disable-next-line no-alert
alert('COUNTERS VALUES ARE EVEN');
},
[state1, state2],
(conditions) => conditions.every((i) => i && i % 2 === 0)
);

return (
<div>
<div>Alert will be displayed when both counters values are even</div>
<div>But effect not invoked on initial mount</div>
<button
onClick={() => {
setState1((i) => i + 1);
}}>
increment counter 1 [{state1}]
</button>{' '}
<button
onClick={() => {
setState2((i) => i + 1);
}}>
increment counter 2 [{state2}]
</button>
</div>
);
};
38 changes: 38 additions & 0 deletions stories/useConditionalUpdateEffect.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks";
import { Example } from "./useConditionalUpdateEffect.stories";

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

# useConditionalUpdateEffect

Like `useUpdateEffect` but callback invoked only if conditions match predicate. By default, predicate
matches if all conditions are truthy.

Like `useUpdateEffect`, it _does not_ perform conditions match on first mount.

#### Example

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

## Reference

```ts
type IUseConditionalUpdateEffectPredicate<Cond extends ReadonlyArray<any>> = (
conditions: Cond
) => boolean;

function useConditionalUpdateEffect<T extends ReadonlyArray<any>>(
callback: React.EffectCallback,
conditions: T,
predicate?: IUseConditionalUpdateEffectPredicate<T>
): void;
```

#### Arguments

- _**callback**_ _`React.EffectCallback`_ - Effect callback like for `React.useEffect` hook
- _**conditions**_ _`ReadonlyArray<any>`_ - Conditions that are matched against predicate
- _**predicate**_ _`IUseConditionalUpdateEffectPredicate<ReadonlyArray<any>>`_ - Predicate that matches conditions.
By default, it is all-truthy provision, meaning that all conditions should be truthy.
7 changes: 6 additions & 1 deletion stories/useIsMounted.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { useIsMounted, useMountEffect, useToggle } from '../src';

export const Example: React.FC = () => {
const [isToggled, toggle] = useToggle(true);
const [isToggled, toggle] = useToggle(false);

const ToggledComponent: React.FC = () => {
const isMounted = useIsMounted();
Expand All @@ -29,6 +29,11 @@ export const Example: React.FC = () => {

return (
<div>
{!isToggled && (
<div>
As example component displays alert without interaction - it is initially unmounted.
</div>
)}
<button
onClick={() => {
toggle();
Expand Down
16 changes: 8 additions & 8 deletions stories/useUnmountEffect.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import * as React from 'react';
import { useUnmountEffect, useToggle } from '../src';

const ToggledComponent: React.FC = () => {
// eslint-disable-next-line no-alert
useUnmountEffect(() => alert('UNMOUNTED'));

return <p>Unmount me</p>;
};
import { useToggle, useUnmountEffect } from '../src';

export const Example: React.FC = () => {
const [isToggled, toggle] = useToggle(false);

const ToggledComponent: React.FC = () => {
// eslint-disable-next-line no-alert
useUnmountEffect(() => alert('UNMOUNTED'));

return <p>Unmount me</p>;
};

return (
<div>
<button
Expand Down
59 changes: 59 additions & 0 deletions tests/dom/useConditionalEffect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useConditionalEffect } from '../../src';

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

it('should render', () => {
renderHook(() => useConditionalEffect(() => {}, []));
});

it('by default should invoke effect only in case all conditions are truthy', () => {
const spy = jest.fn();
const { rerender } = renderHook(({ cond }) => useConditionalEffect(spy, cond), {
initialProps: { cond: [1] as unknown[] },
});
expect(spy).toHaveBeenCalledTimes(1);

rerender({ cond: [0, 1, 1] });
expect(spy).toHaveBeenCalledTimes(1);

rerender({ cond: [1, {}, null] });
expect(spy).toHaveBeenCalledTimes(1);

rerender({ cond: [true, {}, [], 25] });
expect(spy).toHaveBeenCalledTimes(2);
});

it('should not be called on mount if conditions are falsy', () => {
const spy = jest.fn();
renderHook(({ cond }) => useConditionalEffect(spy, cond), {
initialProps: { cond: [null] as unknown[] },
});
expect(spy).toHaveBeenCalledTimes(0);
});

it('should apply custom predicate', () => {
const spy = jest.fn();
const predicateSpy = jest.fn((arr: unknown[]) => arr.some((i) => Boolean(i)));
const { rerender } = renderHook(({ cond }) => useConditionalEffect(spy, cond, predicateSpy), {
initialProps: { cond: [null] as unknown[] },
});
expect(predicateSpy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(0);

rerender({ cond: [true, {}, [], 25] });
expect(predicateSpy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledTimes(1);

rerender({ cond: [true, false, 0, null] });
expect(predicateSpy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenCalledTimes(2);

rerender({ cond: [undefined, false, 0, null] });
expect(predicateSpy).toHaveBeenCalledTimes(4);
expect(spy).toHaveBeenCalledTimes(2);
});
});
Loading

0 comments on commit eb7f0a5

Please sign in to comment.