From 0718afe1569d2b900147bc9756ba3fba0a1bdde5 Mon Sep 17 00:00:00 2001 From: Anton Zinovyev Date: Sat, 1 May 2021 00:05:29 +0300 Subject: [PATCH] feat: new hook `useSafeState` (#31) * feat: new hook `useSafeState` * fix: some typos --- .editorconfig | 3 +- .eslintrc.js | 12 +++++++ README.md | 11 +++--- src/index.ts | 1 + src/useSafeState.ts | 25 +++++++++++++ stories/Introduction.story.mdx | 8 ++--- stories/useConditionalEffect.story.mdx | 4 +-- stories/useConditionalUpdateEffect.story.mdx | 4 +-- stories/useFirstMountState.story.mdx | 4 +-- stories/useIsMounted.story.mdx | 4 +-- stories/useMountEffect.story.mdx | 4 +-- stories/usePrevious.story.mdx | 4 +-- stories/useRerender.story.mdx | 4 +-- stories/useSafeState.story.mdx | 22 ++++++++++++ stories/useToggle.story.mdx | 4 +-- stories/useUnmountEffect.story.mdx | 4 +-- stories/useUpdateEffect.story.mdx | 4 +-- tests/dom/useSafeState.test.ts | 38 ++++++++++++++++++++ tests/ssr/useSafeState.test.ts | 18 ++++++++++ 19 files changed, 149 insertions(+), 29 deletions(-) create mode 100644 src/useSafeState.ts create mode 100644 stories/useSafeState.story.mdx create mode 100644 tests/dom/useSafeState.test.ts create mode 100644 tests/ssr/useSafeState.test.ts diff --git a/.editorconfig b/.editorconfig index cd22621fe..2b05b2c9c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,7 @@ max_line_length = 100 tab_width = 2 trim_trailing_whitespace = true -[*.md] +[*.{md,mdx}] trim_trailing_whitespace = false indent_size = unset +max_line_length = 100 diff --git a/.eslintrc.js b/.eslintrc.js index 27411d713..c7bf61287 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -125,6 +125,18 @@ module.exports = { jsx: true, }, }, + rules: { + 'prettier/prettier': [ + 'error', + { + PRINT_WIDTH, + singleQuote: true, + jsxBracketSameLine: true, + trailingComma: 'es5', + endOfLine: 'lf', + }, + ], + }, }, ], }; diff --git a/README.md b/README.md index 0045b8e14..bc7ec2bd4 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ npm i @react-hookz/web yarn add @react-hookz/web ``` -As hooks was introduced to the world in React 16.8, `@react-hookz/web` requires - you gessed -it - `react` and `react-dom` 16.8+. -Also, as React does not support IE, `@react-hookz/web` does not do so either. You'll have to -transpile your `node-modules` in order to run in IE. +As hooks was introduced to the world in React 16.8, `@react-hookz/web` requires - you gessed it + +- `react` and `react-dom` 16.8+. + Also, as React does not support IE, `@react-hookz/web` does not do so either. You'll have to + transpile your `node-modules` in order to run in IE. ## Usage @@ -71,3 +72,5 @@ import { useMountEffect } from "@react-hookz/web/esnext"; โ€” Like `useState`, but can only become `true` or `false`. - [`usePrevious`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useprevious) โ€” Returns the value passed to the hook on previous render. + - [`useSafeState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usesafestate) + โ€” Like `useState` but its state setter is guarded against sets on unmounted component. diff --git a/src/index.ts b/src/index.ts index d95141366..e24c4baa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export { usePrevious } from './usePrevious'; export { useIsMounted } from './useIsMounted'; export { useConditionalEffect } from './useConditionalEffect'; export { useConditionalUpdateEffect } from './useConditionalUpdateEffect'; +export { useSafeState } from './useSafeState'; diff --git a/src/useSafeState.ts b/src/useSafeState.ts new file mode 100644 index 000000000..1ccfd3f5a --- /dev/null +++ b/src/useSafeState.ts @@ -0,0 +1,25 @@ +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import { useIsMounted } from './useIsMounted'; + +/** + * Like `useState` but its state setter is guarded against sets on unmounted component. + * + * @param initialState + */ +export function useSafeState(initialState: S | (() => S)): [S, Dispatch>]; +export function useSafeState(): [ + S | undefined, + Dispatch> +]; +export function useSafeState(initialState?: S | (() => S)): [S, Dispatch>] { + const [state, setState] = useState(initialState); + const isMounted = useIsMounted(); + + return [ + state, + useCallback((value) => { + if (isMounted()) setState(value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) as Dispatch>, + ]; +} diff --git a/stories/Introduction.story.mdx b/stories/Introduction.story.mdx index 49924a8b4..4b890cbc7 100644 --- a/stories/Introduction.story.mdx +++ b/stories/Introduction.story.mdx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/addon-docs/blocks"; +import { Meta } from '@storybook/addon-docs/blocks'; @@ -44,9 +44,9 @@ So, if you need the `useMountEffect` hook, depending on your needs, you can impo ```ts // in case you need cjs modules -import { useMountEffect } from "@react-hookz/web"; +import { useMountEffect } from '@react-hookz/web'; // in case you need esm modules -import { useMountEffect } from "@react-hookz/web/esm"; +import { useMountEffect } from '@react-hookz/web/esm'; // in case you want all the recent ES features -import { useMountEffect } from "@react-hookz/web/esnext"; +import { useMountEffect } from '@react-hookz/web/esnext'; ``` diff --git a/stories/useConditionalEffect.story.mdx b/stories/useConditionalEffect.story.mdx index 019b39ed2..480b84dc9 100644 --- a/stories/useConditionalEffect.story.mdx +++ b/stories/useConditionalEffect.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useConditionalEffect.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useConditionalEffect.stories'; diff --git a/stories/useConditionalUpdateEffect.story.mdx b/stories/useConditionalUpdateEffect.story.mdx index fb5a4e161..4e6bb03dc 100644 --- a/stories/useConditionalUpdateEffect.story.mdx +++ b/stories/useConditionalUpdateEffect.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useConditionalUpdateEffect.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useConditionalUpdateEffect.stories'; diff --git a/stories/useFirstMountState.story.mdx b/stories/useFirstMountState.story.mdx index 75b1cc82d..bd0bdc481 100644 --- a/stories/useFirstMountState.story.mdx +++ b/stories/useFirstMountState.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story, Typeset } from "@storybook/addon-docs/blocks"; -import { Example } from "./useFirstMountState.stories"; +import { Canvas, Meta, Story, Typeset } from '@storybook/addon-docs/blocks'; +import { Example } from './useFirstMountState.stories'; diff --git a/stories/useIsMounted.story.mdx b/stories/useIsMounted.story.mdx index f6a953373..f4517884a 100644 --- a/stories/useIsMounted.story.mdx +++ b/stories/useIsMounted.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useIsMounted.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useIsMounted.stories'; diff --git a/stories/useMountEffect.story.mdx b/stories/useMountEffect.story.mdx index 43256d560..613bcc322 100644 --- a/stories/useMountEffect.story.mdx +++ b/stories/useMountEffect.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useMountEffect.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useMountEffect.stories'; diff --git a/stories/usePrevious.story.mdx b/stories/usePrevious.story.mdx index fb08785f4..780235a96 100644 --- a/stories/usePrevious.story.mdx +++ b/stories/usePrevious.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./usePrevious.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './usePrevious.stories'; diff --git a/stories/useRerender.story.mdx b/stories/useRerender.story.mdx index 0b03dba2d..bf03f3806 100644 --- a/stories/useRerender.story.mdx +++ b/stories/useRerender.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useRerender.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useRerender.stories'; diff --git a/stories/useSafeState.story.mdx b/stories/useSafeState.story.mdx new file mode 100644 index 000000000..6a7adfbbf --- /dev/null +++ b/stories/useSafeState.story.mdx @@ -0,0 +1,22 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# useSafeState + +Have you ever been caught on `Can't perform a React state update on an unmounted component.`? Of +course you do, we're all been there๐Ÿ˜… +Async callback invoked, it tries to set state, but component is already unmounted, that annoying +warning happens, and you have to track component mount state manually. + +`useSafeState` covers your back - it tracks component mount state and does not perform `setState` +action if component is unmounted, otherwise it is the same hook as common `useState`. + +#### Example + +Sadly we can't provide an example since this documentation built in `production` mode and warning +are only shown in `development` mode. + +## Reference + +Use it exactly the same as `useState`. diff --git a/stories/useToggle.story.mdx b/stories/useToggle.story.mdx index 3cd21b748..2e43a86f6 100644 --- a/stories/useToggle.story.mdx +++ b/stories/useToggle.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useToggle.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useToggle.stories'; diff --git a/stories/useUnmountEffect.story.mdx b/stories/useUnmountEffect.story.mdx index b7a7c719a..476e18609 100644 --- a/stories/useUnmountEffect.story.mdx +++ b/stories/useUnmountEffect.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useUnmountEffect.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useUnmountEffect.stories'; diff --git a/stories/useUpdateEffect.story.mdx b/stories/useUpdateEffect.story.mdx index 039bf6872..acce452a6 100644 --- a/stories/useUpdateEffect.story.mdx +++ b/stories/useUpdateEffect.story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./useUpdateEffect.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useUpdateEffect.stories'; diff --git a/tests/dom/useSafeState.test.ts b/tests/dom/useSafeState.test.ts new file mode 100644 index 000000000..7d733730d --- /dev/null +++ b/tests/dom/useSafeState.test.ts @@ -0,0 +1,38 @@ +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { useSafeState } from '../../src'; + +describe('useSafeState', () => { + it('should be defined', () => { + expect(useSafeState).toBeDefined(); + }); + + it('should render', () => { + renderHook(() => useSafeState()); + }); + + it('should not call ', () => { + const consoleSpy = jest.spyOn(console, 'error'); + consoleSpy.mockImplementationOnce(() => {}); + + const { result, unmount } = renderHook(() => useSafeState(1)); + expect(result.current[1]).toBeInstanceOf(Function); + expect(result.current[0]).toBe(1); + + act(() => { + result.current[1](321); + }); + + expect(result.current[0]).toBe(321); + + unmount(); + + act(() => { + result.current[1](123); + }); + + expect(consoleSpy).toHaveBeenCalledTimes(0); + expect(result.current[0]).toBe(321); + + consoleSpy.mockRestore(); + }); +}); diff --git a/tests/ssr/useSafeState.test.ts b/tests/ssr/useSafeState.test.ts new file mode 100644 index 000000000..1b08ce78d --- /dev/null +++ b/tests/ssr/useSafeState.test.ts @@ -0,0 +1,18 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useSafeState } from '../../src'; + +describe('useSafeState', () => { + it('should be defined', () => { + expect(useSafeState).toBeDefined(); + }); + + it('should render', () => { + renderHook(() => useSafeState()); + }); + + it('should not call ', () => { + const { result } = renderHook(() => useSafeState(1)); + expect(result.current[1]).toBeInstanceOf(Function); + expect(result.current[0]).toBe(1); + }); +});