diff --git a/README.md b/README.md index 44a56cf..9172664 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A collection of commonly used hooks for React apps. **Packages:** - [`use-promised`](./packages/use-promised) -- [`use-optionally-controlled-state`](./packages/use-optionally-controlled-state) +- [`use-optional-state`](./packages/use-optional-state) - [`use-last`](./packages/use-last) - [`use-debounced`](./packages/use-debounced) - [`use-presence`](./packages/use-presence) diff --git a/packages/use-debounced/package.json b/packages/use-debounced/package.json index 8981eff..640a0c5 100644 --- a/packages/use-debounced/package.json +++ b/packages/use-debounced/package.json @@ -38,6 +38,6 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "tsdx": "^0.14.1", - "typescript": "^4.6.3" + "typescript": "^4.8.2" } } diff --git a/packages/use-last/package.json b/packages/use-last/package.json index 14cad53..e02db98 100644 --- a/packages/use-last/package.json +++ b/packages/use-last/package.json @@ -38,6 +38,6 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "tsdx": "^0.14.1", - "typescript": "^4.6.3" + "typescript": "^4.8.2" } } diff --git a/packages/use-optionally-controlled-state/.eslintrc.js b/packages/use-optional-state/.eslintrc.js similarity index 100% rename from packages/use-optionally-controlled-state/.eslintrc.js rename to packages/use-optional-state/.eslintrc.js diff --git a/packages/use-optionally-controlled-state/README.md b/packages/use-optional-state/README.md similarity index 82% rename from packages/use-optionally-controlled-state/README.md rename to packages/use-optional-state/README.md index 81e5690..75ce35b 100644 --- a/packages/use-optionally-controlled-state/README.md +++ b/packages/use-optional-state/README.md @@ -1,6 +1,6 @@ -# use-optionally-controlled-state +# use-optional-state -[](https://npm.im/use-optionally-controlled-state) +[](https://npm.im/use-optional-state) A React hook to enable a component state to either be controlled or uncontrolled. @@ -18,21 +18,21 @@ When implementing a component, it's sometimes hard to choose one or the other si This hook helps you to support both patterns in your components, increasing flexibility while also ensuring ease of use. -Since the solution can be applied on a per-prop basis, you can even enable this behaviour for multiple props that are orthogonal (e.g. a `<Prompt isOpen inputValue="" />` component). +Since the solution can be applied on a per-prop basis, you can also enable this behaviour for multiple props that are orthogonal (e.g. a `<Prompt isOpen inputValue="" />` component). ## Example **Implementation:** ```jsx -import useOptionallyControlledState from 'use-optionally-controlled-state'; +import useOptionalState from 'use-optional-state'; function Expander({ expanded: controlledExpanded, initialExpanded = false, onChange }) { - const [expanded, setExpanded] = useOptionallyControlledState({ + const [expanded, setExpanded] = useOptionalState({ controlledValue: controlledExpanded, initialValue: initialExpanded, onChange diff --git a/packages/use-optionally-controlled-state/package.json b/packages/use-optional-state/package.json similarity index 87% rename from packages/use-optionally-controlled-state/package.json rename to packages/use-optional-state/package.json index 08f8e4c..af4d377 100644 --- a/packages/use-optionally-controlled-state/package.json +++ b/packages/use-optional-state/package.json @@ -1,12 +1,12 @@ { - "name": "use-optionally-controlled-state", + "name": "use-optional-state", "version": "1.2.0", "license": "MIT", "description": "A React hook to implement components that support both controlled and uncontrolled props.", "author": "Jan Amann <jan@amann.me>", "repository": { "type": "git", - "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-optionally-controlled-state" + "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-optional-state" }, "scripts": { "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", @@ -16,7 +16,7 @@ "prepublish": "npm run build" }, "main": "dist/index.js", - "module": "dist/use-optionally-controlled-state.esm.js", + "module": "dist/use-optional-state.esm.js", "typings": "dist/index.d.ts", "files": [ "README.md", @@ -42,6 +42,6 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "tsdx": "^0.14.1", - "typescript": "^4.6.3" + "typescript": "^4.8.2" } } diff --git a/packages/use-optionally-controlled-state/src/index.tsx b/packages/use-optional-state/src/index.tsx similarity index 55% rename from packages/use-optionally-controlled-state/src/index.tsx rename to packages/use-optional-state/src/index.tsx index 868b424..2bd2112 100644 --- a/packages/use-optionally-controlled-state/src/index.tsx +++ b/packages/use-optional-state/src/index.tsx @@ -1,37 +1,44 @@ import {useState, useCallback} from 'react'; import useConstant from 'use-constant'; -type Options<Value> = - | { - controlledValue?: Value; - initialValue: Value; - onChange?(value: Value): void; - } - | { - controlledValue: Value; - initialValue?: Value; - onChange?(value: Value): void; - }; +// Controlled +export default function useOptionalState<Value>(opts: { + controlledValue: Value; + initialValue?: Value | undefined; + onChange?(value: Value): void; +}): [Value, (value: Value) => void]; + +// Uncontrolled with initial value +export default function useOptionalState<Value>(opts: { + controlledValue?: Value | undefined; + initialValue: Value; + onChange?(value: Value): void; +}): [Value | undefined, (value: Value) => void]; + +// Uncontrolled without initial value +export default function useOptionalState<Value>(opts: { + controlledValue?: Value | undefined; + initialValue?: Value; + onChange?(value: Value): void; +}): [Value | undefined, (value: Value) => void]; /** * Enables a component state to be either controlled or uncontrolled. */ -export default function useOptionallyControlledState<Value>({ +export default function useOptionalState<Value>({ controlledValue, initialValue, onChange -}: Options<Value>): [Value, (value: Value) => void] { +}: { + controlledValue?: Value | undefined; + initialValue?: Value | undefined; + onChange?(value: Value): void; +}) { const isControlled = controlledValue !== undefined; const initialIsControlled = useConstant(() => isControlled); const [stateValue, setStateValue] = useState(initialValue); if (__DEV__) { - if (initialValue === undefined && controlledValue === undefined) { - throw new Error( - 'Either an initial or a controlled value should be provided.' - ); - } - if (initialIsControlled && !isControlled) { throw new Error( 'Can not change from controlled to uncontrolled mode. If `undefined` needs to be used for controlled values, please use `null` instead.' @@ -45,15 +52,14 @@ export default function useOptionallyControlledState<Value>({ } } - // Options type ensures that either `controlledValue` or `stateValue` is defined - const value = (isControlled ? controlledValue : stateValue)!; + const value = isControlled ? controlledValue : stateValue; const onValueChange = useCallback( (nextValue: Value) => { if (!isControlled) setStateValue(nextValue); if (onChange) onChange(nextValue); }, - [onChange, isControlled] + [isControlled, onChange] ); return [value, onValueChange]; diff --git a/packages/use-optionally-controlled-state/test/index.test.tsx b/packages/use-optional-state/test/index.test.tsx similarity index 51% rename from packages/use-optionally-controlled-state/test/index.test.tsx rename to packages/use-optional-state/test/index.test.tsx index ef0d90d..67b5553 100644 --- a/packages/use-optionally-controlled-state/test/index.test.tsx +++ b/packages/use-optional-state/test/index.test.tsx @@ -1,6 +1,6 @@ import {fireEvent, render, screen} from '@testing-library/react'; import * as React from 'react'; -import useOptionallyControlledState from '../src'; +import useOptionalState from '../src'; (global as any).__DEV__ = true; @@ -15,7 +15,7 @@ function Expander({ initialExpanded, onChange }: Props) { - const [expanded, setExpanded] = useOptionallyControlledState({ + const [expanded, setExpanded] = useOptionalState({ controlledValue: controlledExpanded, initialValue: initialExpanded, onChange @@ -66,6 +66,20 @@ it('supports an uncontrolled mode', () => { screen.getByText('Children'); }); +it('supports an uncontrolled mode with no initial value', () => { + const onChange = jest.fn(); + + render(<Expander onChange={onChange} />); + expect(screen.queryByText('Children')).toBe(null); + fireEvent.click(screen.getByText('Toggle')); + expect(onChange).toHaveBeenLastCalledWith(true); + + screen.getByText('Children'); + fireEvent.click(screen.getByText('Toggle')); + expect(onChange).toHaveBeenLastCalledWith(false); + expect(screen.queryByText('Children')).toBe(null); +}); + it('allows to use an initial value without a change handler', () => { // Maybe the value is read from the DOM directly render(<Expander initialExpanded />); @@ -81,12 +95,6 @@ it('uses the controlled value when both a controlled as well as an initial value screen.getByText('Children'); }); -it('throws when neither a controlled nor an initial value is provided', () => { - expect(() => render(<Expander />)).toThrow( - 'Either an initial or a controlled value should be provided.' - ); -}); - it('throws when switching from uncontrolled to controlled mode', () => { const {rerender} = render(<Expander initialExpanded />); @@ -102,3 +110,73 @@ it('throws when switching from controlled to uncontrolled mode', () => { /Can not change from controlled to uncontrolled mode./ ); }); + +/** + * Type signature tests + */ + +function TestTypes() { + const controlled = useOptionalState({ + controlledValue: true + }); + controlled[0].valueOf(); + + const uncontrolledWithInitialValue = useOptionalState({ + initialValue: true + }); + // @ts-expect-error Null-check would be necessary + uncontrolledWithInitialValue[0].valueOf(); + + const uncontrolledWithoutInitialValue = useOptionalState<boolean>({}); + // @ts-expect-error Null-check would be necessary + uncontrolledWithoutInitialValue[0].valueOf(); + + // Only used for type tests; mark the variables as used + // eslint-disable-next-line no-unused-expressions + [controlled, uncontrolledWithInitialValue, uncontrolledWithoutInitialValue]; +} + +// Expected return type: `[boolean, (value: boolean) => void]` +function Controlled(opts: {controlledValue: boolean; initialValue?: boolean}) { + const [value, setValue] = useOptionalState(opts); + + setValue(true); + return value.valueOf(); +} + +// Expected return type: `[boolean | undefined, (value: boolean) => void]` +// Note that theoretically `undefined` shouldn't be possible here, +// but the types seem to be quite hard to get right. +function UncontrolledWithInitialValue(opts: { + controlledValue?: boolean; + initialValue: boolean; +}) { + const [value, setValue] = useOptionalState(opts); + + setValue(true); + + // @ts-expect-error Null-check would be necessary + return value.valueOf(); +} + +// Expected return type: `[boolean | undefined, (value: boolean) => void]` +function UncontrolledWithoutInitialValue(opts: { + controlledValue?: boolean; + initialValue?: boolean; +}) { + const [value, setValue] = useOptionalState(opts); + + setValue(true); + + // @ts-expect-error Null-check would be necessary + return value.valueOf(); +} + +// Only used for type tests; mark the functions as used +// eslint-disable-next-line no-unused-expressions +[ + TestTypes, + Controlled, + UncontrolledWithInitialValue, + UncontrolledWithoutInitialValue +]; diff --git a/packages/use-optionally-controlled-state/tsconfig.json b/packages/use-optional-state/tsconfig.json similarity index 100% rename from packages/use-optionally-controlled-state/tsconfig.json rename to packages/use-optional-state/tsconfig.json diff --git a/packages/use-presence/package.json b/packages/use-presence/package.json index df82a69..4292d85 100644 --- a/packages/use-presence/package.json +++ b/packages/use-presence/package.json @@ -38,6 +38,6 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "tsdx": "^0.14.1", - "typescript": "^4.6.3" + "typescript": "^4.8.2" } } diff --git a/packages/use-promised/package.json b/packages/use-promised/package.json index 0120dfb..ed0b59b 100644 --- a/packages/use-promised/package.json +++ b/packages/use-promised/package.json @@ -38,6 +38,6 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "tsdx": "^0.14.1", - "typescript": "^4.6.3" + "typescript": "^4.8.2" } } diff --git a/yarn.lock b/yarn.lock index 9573882..b241a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2122,6 +2122,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/eslint-visitor-keys@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" + integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag== + "@types/estree@*": version "0.0.45" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884" @@ -2300,7 +2305,17 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@5.20.0", "@typescript-eslint/parser@^2.12.0", "@typescript-eslint/parser@^5.0.0": +"@typescript-eslint/parser@^2.12.0": + version "2.34.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.34.0.tgz#50252630ca319685420e9a39ca05fe185a256bc8" + integrity sha512-03ilO0ucSD0EPTw2X4PntSIRFtDPWjrVq7C3/Z3VQHRC7+13YB55rcJI3Jt+YgeHbjUdJPcPa7b23rXCBokuyA== + dependencies: + "@types/eslint-visitor-keys" "^1.0.0" + "@typescript-eslint/experimental-utils" "2.34.0" + "@typescript-eslint/typescript-estree" "2.34.0" + eslint-visitor-keys "^1.1.0" + +"@typescript-eslint/parser@^5.0.0": version "5.20.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.20.0.tgz#4991c4ee0344315c2afc2a62f156565f689c8d0b" integrity sha512-UWKibrCZQCYvobmu3/N8TWbEeo/EPQbS41Ux1F9XqPzGuV7pfg6n50ZrFo6hryynD8qOTTfLHtHjjdQtxJ0h/w== @@ -9444,11 +9459,16 @@ typescript@^3.7.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== -typescript@^4.0.0, typescript@^4.6.3: +typescript@^4.0.0: version "4.6.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== +typescript@^4.8.2: + version "4.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" + integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== + uglify-js@^3.1.4: version "3.10.4" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.10.4.tgz#dd680f5687bc0d7a93b14a3482d16db6eba2bfbb"