Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow providing neither a controlled nor an initial value #10

Merged
merged 8 commits into from
Aug 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

- [`use-last`](./packages/use-last)
- [`use-debounced`](./packages/use-debounced)
- [`use-presence`](./packages/use-presence)
Expand Down
2 changes: 1 addition & 1 deletion packages/use-debounced/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion packages/use-last/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# use-optionally-controlled-state
# use-optional-state

[![Stable release](https://img.shields.io/npm/v/use-optionally-controlled-state.svg)](https://npm.im/use-optionally-controlled-state)
[![Stable release](https://img.shields.io/npm/v/use-optional-state.svg)](https://npm.im/use-optional-state)

A React hook to enable a component state to either be controlled or uncontrolled.

Expand All @@ -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).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that the initial excitement has worn off 😉

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, right 😆. I found it unnecessarily salesy on a second look 😁


## 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"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",
Expand All @@ -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",
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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.'
Expand All @@ -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];
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,7 +15,7 @@ function Expander({
initialExpanded,
onChange
}: Props) {
const [expanded, setExpanded] = useOptionallyControlledState({
const [expanded, setExpanded] = useOptionalState({
controlledValue: controlledExpanded,
initialValue: initialExpanded,
onChange
Expand Down Expand Up @@ -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 />);
Expand All @@ -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 />);

Expand All @@ -102,3 +110,73 @@ it('throws when switching from controlled to uncontrolled mode', () => {
/Can not change from controlled to uncontrolled mode./
);
});

/**
* Type signature tests

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, clever! I haven't seen this before...

*/

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
];
2 changes: 1 addition & 1 deletion packages/use-presence/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion packages/use-promised/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
24 changes: 22 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2300,7 +2305,17 @@
eslint-scope "^5.0.0"
eslint-utils "^2.0.0"

"@typescript-eslint/[email protected]", "@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==
Expand Down Expand Up @@ -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"
Expand Down