diff --git a/docs/usePreviousDistinct.md b/docs/usePreviousDistinct.md new file mode 100644 index 0000000000..484110aa25 --- /dev/null +++ b/docs/usePreviousDistinct.md @@ -0,0 +1,51 @@ +# `usePreviousDistinct` + +Just like `usePrevious` but it will only update once the value actually changes. This is important when other +hooks are involved and you aren't just interested in the previous props version, but want to know the previous +distinct value + +## Usage + +```jsx +import {usePreviousDistinct, useCounter} from 'react-use'; + +const Demo = () => { + const [count, { inc: relatedInc }] = useCounter(0); + const [unrelatedCount, { inc }] = useCounter(0); + const prevCount = usePreviousDistinct(count); + + return ( +

+ Now: {count}, before: {prevCount} + + Unrelated: {unrelatedCount} + +

+ ); +}; +``` + +You can also provide a way of identifying the value as unique. By default, a strict equals is used. + +```jsx +import {usePreviousDistinct} from 'react-use'; + +const Demo = () => { + const [str, setStr] = React.useState("something_lowercase"); + const [unrelatedCount] = React.useState(0); + const prevStr = usePreviousDistinct(str, (prev, next) => (prev && prev.toUpperCase()) === next.toUpperCase()); + + return ( +

+ Now: {count}, before: {prevCount} + Unrelated: {unrelatedCount} +

+ ); +}; +``` + +## Reference + +```ts +const prevState = usePreviousDistinct = (state: T, compare?: (prev: T | undefined, next: T) => boolean): T; +``` diff --git a/src/__stories__/usePreviousDistinct.story.tsx b/src/__stories__/usePreviousDistinct.story.tsx new file mode 100644 index 0000000000..fa9bebefe7 --- /dev/null +++ b/src/__stories__/usePreviousDistinct.story.tsx @@ -0,0 +1,23 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { usePreviousDistinct, useCounter } from '..'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const [count, { inc: relatedInc }] = useCounter(0); + const [unrelatedCount, { inc }] = useCounter(0); + const prevCount = usePreviousDistinct(count); + + return ( +

+ Now: {count}, before: {prevCount} + + Unrelated: {unrelatedCount} + +

+ ); +}; + +storiesOf('State|usePreviousDistinct', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__tests__/usePreviousDistinct.test.tsx b/src/__tests__/usePreviousDistinct.test.tsx new file mode 100644 index 0000000000..073c86b11c --- /dev/null +++ b/src/__tests__/usePreviousDistinct.test.tsx @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react-hooks'; +import usePreviousDistinct from '../usePreviousDistinct'; + +describe('usePreviousDistinct with default compare', () => { + const hook = renderHook(props => usePreviousDistinct(props), { initialProps: 0 }); + + it('should return undefined on initial render', () => { + expect(hook.result.current).toBe(undefined); + }); + + it('should return previous state only after a different value is rendered', () => { + expect(hook.result.current).toBeUndefined(); + hook.rerender(1); + expect(hook.result.current).toBe(0); + hook.rerender(2); + hook.rerender(2); + expect(hook.result.current).toBe(1); + + hook.rerender(3); + expect(hook.result.current).toBe(2); + }); +}); + +describe('usePreviousDistinct with complex comparison', () => { + const exampleObjects = [ + { + id: 'something-unique', + name: 'Nancy', + }, + { + id: 'something-unique2', + name: 'Fred', + }, + { + id: 'something-unique3', + name: 'Bill', + }, + { + id: 'something-unique4', + name: 'Alice', + }, + ]; + const hook = renderHook( + props => usePreviousDistinct(props, (prev, next) => (prev && prev.id) === (next && next.id)), + { + initialProps: exampleObjects[0], + } + ); + + it('should return undefined on initial render', () => { + expect(hook.result.current).toBe(undefined); + }); + + it('should return previous state only after a different value is rendered', () => { + expect(hook.result.current).toBeUndefined(); + hook.rerender(exampleObjects[1]); + expect(hook.result.current).toMatchObject(exampleObjects[0]); + hook.rerender(exampleObjects[2]); + hook.rerender(exampleObjects[2]); + expect(hook.result.current).toMatchObject(exampleObjects[1]); + + hook.rerender(exampleObjects[3]); + expect(hook.result.current).toMatchObject(exampleObjects[2]); + }); +}); diff --git a/src/index.ts b/src/index.ts index 5a83944fc6..9fc6319435 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ export { default as useOrientation } from './useOrientation'; export { default as usePageLeave } from './usePageLeave'; export { default as usePermission } from './usePermission'; export { default as usePrevious } from './usePrevious'; +export { default as usePreviousDistinct } from './usePreviousDistinct'; export { default as usePromise } from './usePromise'; export { default as useRaf } from './useRaf'; export { default as useRafLoop } from './useRafLoop'; diff --git a/src/usePreviousDistinct.ts b/src/usePreviousDistinct.ts new file mode 100644 index 0000000000..c78b00bcd2 --- /dev/null +++ b/src/usePreviousDistinct.ts @@ -0,0 +1,19 @@ +import { useRef } from 'react'; + +function strictEquals(prev: T | undefined, next: T) { + return prev === next; +} + +export default function usePreviousDistinct( + value: T, + compare: (prev: T | undefined, next: T) => boolean = strictEquals +) { + const prevRef = useRef(); + const curRef = useRef(); + if (!compare(curRef.current, value)) { + prevRef.current = curRef.current; + curRef.current = value; + } + + return prevRef.current; +}