Skip to content

Commit

Permalink
refactor(react): consolidate useControllableState to one hook (#10255)
Browse files Browse the repository at this point in the history
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
joshblack and kodiakhq[bot] committed Dec 9, 2021
1 parent cb04de8 commit 8a4958e
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 124 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import React from 'react';
import cx from 'classnames';
import { match, matches, keys } from '../../../internal/keyboard';
import { useId } from '../../../internal/useId';
import { useControllableState } from './useControllableState';
import { useControllableState } from '../../../internal/useControllableState';

const { prefix } = settings;

Expand Down

This file was deleted.

2 changes: 1 addition & 1 deletion packages/react/src/components/Tabs/next/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { keys, match, matches } from '../../../internal/keyboard';
import { usePrefix } from '../../../internal/usePrefix';
import { useId } from '../../../internal/useId';
import { getInteractiveContent } from '../../../internal/useNoInteractiveChildren';
import { useControllableState } from '../../ContentSwitcher/next/useControllableState';
import { useControllableState } from '../../../internal/useControllableState';
import { useMergedRefs } from '../../../internal/useMergedRefs';

// Used to manage the overall state of the Tabs
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/components/Toggle/next/Toggle.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ export function Toggle({
toggled,
...other
}) {
const [checked, setChecked] = useControllableState(
toggled,
onToggle,
defaultToggled
);
const [checked, setChecked] = useControllableState({
value: toggled,
onChange: onToggle,
defaultValue: defaultToggled,
});

function handleClick(e) {
setChecked(!checked);
Expand Down
33 changes: 20 additions & 13 deletions packages/react/src/internal/__tests__/useControllableState-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/

import { cleanup, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
import { useControllableState } from '../useControllableState';

describe('useControllableState', () => {
afterEach(cleanup);

test('uncontrolled', () => {
render(<TextInput />);
userEvent.type(screen.getByTestId('input'), 'test');
Expand All @@ -26,28 +24,37 @@ describe('useControllableState', () => {
});

test('controlled to uncontrolled', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});

render(<Toggle defaultControlled={true} />);
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});

render(<Toggle defaultControlled />);
userEvent.click(screen.getByTestId('toggle'));
expect(spy).toHaveBeenCalled();
spy.mockRestore();

expect(error).toHaveBeenCalled();
expect(warn).toHaveBeenCalled();

error.mockRestore();
warn.mockRestore();
});

test('uncontrolled to controlled', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});

render(<Toggle defaultControlled={false} />);

userEvent.click(screen.getByTestId('toggle'));
expect(spy).toHaveBeenCalled();
spy.mockRestore();

expect(warn).toHaveBeenCalled();

warn.mockRestore();
});
});

function TextInput({ onChange, value: controlledValue }) {
const [value, setValue] = useControllableState(controlledValue, onChange, '');
const [value, setValue] = useControllableState({
value: controlledValue,
defaultValue: '',
onChange,
});

function handleOnChange(event) {
setValue(event.target.value);
Expand Down
92 changes: 57 additions & 35 deletions packages/react/src/internal/useControllableState.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,68 +8,90 @@
import { useEffect, useRef, useState } from 'react';
import { warning } from './warning';

export function useControllableState(
controlledState,
controlledSetState,
defaultValue
) {
const controlled = useRef(controlledState !== undefined);
const [state, internalSetState] = useState(() => {
if (controlled.current === true) {
return controlledState;
}
return defaultValue;
});
/**
* This custom hook simplifies the behavior of a component if it has state that
* can be both controlled and uncontrolled. It functions identical to a
* useState() hook and provides [state, setState] for you to use. You can use
* the `onChange` argument to allow updates to the `state` to be communicated to
* owners of controlled components.
*
* Note: this hook will warn if a component is switching from controlled to
* uncontrolled, or vice-verse.
*
* @param {object} config
* @param {string} config.name - the name of the custom component
* @param {any} config.defaultValue - the default value used for the state. This will be
* the fallback value used if `value` is not defined.
* @param {Function} config.onChange - an optional function that is called when
* the value of the state changes. This is useful for communicating to parents of
* controlled components that the value is requesting to be changed.
* @param {any} config.value - a controlled value. Omitting this means that the state is
* uncontrolled
* @returns {[any, Function]}
*/
export function useControllableState({
defaultValue,
name = 'custom',
onChange,
value,
}) {
const [state, internalSetState] = useState(value ?? defaultValue);
const controlled = useRef(null);

// If the owner is controlling the component prop value, keep the controlled
// state value and the internal state value in sync.
//
// We guard on `undefined` to prevent downstream breakage of controlled
// components (like <input>). When the controlled state switches to
// `undefined`, we are moving from controlled to uncontrolled.
if (
controlled.current === true &&
controlledState !== state &&
controlledState !== undefined
) {
internalSetState(controlledState);
if (controlled.current === null) {
controlled.current = value !== undefined;
}

function setState(stateOrUpdater) {
if (controlled.current === true) {
controlledSetState(stateOrUpdater);
} else {
internalSetState(stateOrUpdater);
const value =
typeof stateOrUpdater === 'function'
? stateOrUpdater(state)
: stateOrUpdater;

if (controlled.current === false) {
internalSetState(value);
}

if (onChange) {
onChange(value);
}
}

useEffect(() => {
const controlledValue = value !== undefined;

// Uncontrolled -> Controlled
// If the component prop is uncontrolled, the prop value should be undefined
if (controlled.current === false && controlledState !== undefined) {
if (controlled.current === false && controlledValue) {
warning(
false,
'A component is changing an uncontrolled component to be controlled. ' +
'A component is changing an uncontrolled %s component to be controlled. ' +
'This is likely caused by the value changing to a defined value ' +
'from undefined. Decide between using a controlled or uncontrolled ' +
'value for the lifetime of the component. ' +
'More info: https://reactjs.org/link/controlled-components'
'More info: https://reactjs.org/link/controlled-components',
name
);
}

// Controlled -> Uncontrolled
// If the component prop is controlled, the prop value should be defined
if (controlled.current === true && controlledState === undefined) {
if (controlled.current === true && !controlledValue) {
warning(
false,
'A component is changing a controlled component to be uncontrolled. ' +
'A component is changing a controlled %s component to be uncontrolled. ' +
'This is likely caused by the value changing to an undefined value ' +
'from a defined one. Decide between using a controlled or ' +
'uncontrolled value for the lifetime of the component. ' +
'More info: https://reactjs.org/link/controlled-components'
'More info: https://reactjs.org/link/controlled-components',
name
);
}
}, [controlledState]);
}, [name, value]);

if (controlled.current === true) {
return [value, setState];
}

return [state, setState];
}

0 comments on commit 8a4958e

Please sign in to comment.