Skip to content

Commit

Permalink
feat(react): add useControllableState (#7980)
Browse files Browse the repository at this point in the history
* feat(react): add useControllableState

* Update packages/react/src/internal/useControllableState.js
  • Loading branch information
joshblack authored Mar 9, 2021
1 parent 806f4d2 commit 51b1924
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 0 deletions.
90 changes: 90 additions & 0 deletions packages/react/src/internal/__tests__/useControllableState-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import { cleanup, 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');
expect(screen.getByTestId('input').value).toBe('test');
});

test('controlled', () => {
render(<ControlledTextInput />);
userEvent.type(screen.getByTestId('input'), 'test');
expect(screen.getByTestId('input').value).toBe('test');
});

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

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

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

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

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

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

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

function handleOnChange(event) {
setValue(event.target.value);
}

return (
<input
data-testid="input"
type="text"
onChange={handleOnChange}
value={value}
/>
);
}

function ControlledTextInput() {
const [value, setValue] = useState('');
return <TextInput value={value} onChange={setValue} />;
}

function Toggle({ defaultControlled }) {
const [value, setValue] = useState('');
const [controlled, setControlled] = useState(defaultControlled);
return (
<>
<TextInput
value={controlled ? value : undefined}
onChange={controlled ? setValue : undefined}
/>
<button
data-testid="toggle"
type="button"
onClick={() => {
setControlled(!controlled);
}}>
toggle
</button>
</>
);
}
75 changes: 75 additions & 0 deletions packages/react/src/internal/useControllableState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import { useEffect, useRef, useState } from 'react';
import { warn } from './warn';

export function useControllableState(
controlledState,
controlledSetState,
defaultValue
) {
const controlled = useRef(controlledState !== undefined);
const [state, internalSetState] = useState(() => {
if (controlled.current === true) {
return controlledState;
}
return defaultValue;
});

// 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);
}

function setState(stateOrUpdater) {
if (controlled.current === true) {
controlledSetState(stateOrUpdater);
} else {
internalSetState(stateOrUpdater);
}
}

useEffect(() => {
// Uncontrolled -> Controlled
// If the component prop is uncontrolled, the prop value should be undefined
if (controlled.current === false && controlledState !== undefined) {
warn(
false,
'A component is changing an uncontrolled 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'
);
}

// Controlled -> Uncontrolled
// If the component prop is controlled, the prop value should be defined
if (controlled.current === true && controlledState === undefined) {
warn(
false,
'A component is changing a controlled 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'
);
}
}, [controlledState]);

return [state, setState];
}
30 changes: 30 additions & 0 deletions packages/react/src/internal/warn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

const emptyFunction = function () {};

const warn = __DEV__
? function warn(condition, format, ...args) {
if (format === undefined) {
throw new Error(
'`warn(condition, format, ...args)` requires a warning ' +
'message argument'
);
}

if (!condition) {
let index = 0;
const message = format.replace(/%s/g, () => {
return args[index++];
});

console.warn('Warning: ' + message);
}
}
: emptyFunction;

export { warn };

0 comments on commit 51b1924

Please sign in to comment.