# React Hooks Example
Real world component — `CategoryTypeahead`
## Original (class component)
<details>
<summary>Code</summary>
```jsx
import Immutable from 'immutable';
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import { identity } from 'ramda';
import React from 'react';
import IPropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import * as EventSelectors from 'state/events/selectors';
import * as UttTaxonomyActions from 'state/utt/actions';
import * as UttTaxonomySelectors from 'state/utt/selectors';
import InternalProps from 'modules/internal-props';
import { SimpleSelectInput, SimpleTypeaheadTransport } from 'components/Inputs/Select';
import { UttMetadata } from 'models/events';
class CategoryTypeahead extends React.Component {
static propTypes = {
eventAnnotations: IPropTypes.listOf(PropTypes.string),
getUttName: PropTypes.func,
onAddCategory: PropTypes.func,
renderOption: PropTypes.func,
requestUttTaxonomy: PropTypes.func,
uttMetadata: InternalProps.UttMetadata,
uttTaxonomy: InternalProps.UttTaxonomy,
uttTaxonomyLoading: PropTypes.bool,
};
static defaultProps = {
uttMetadata: new UttMetadata(),
};
state = {
transport: new SimpleTypeaheadTransport(),
searchTerm: '',
};
componentDidMount() {
this.debouncedLoadData();
}
componentDidUpdate(prevProps, prevState) {
const { eventAnnotations, uttMetadata } = this.props;
const { searchTerm } = this.state;
const eventAnnotationsChanged = prevProps.eventAnnotations !== eventAnnotations;
const uttMetadataChanged = prevProps.uttMetadata !== uttMetadata;
const searchTermChanged = prevState.searchTerm !== searchTerm;
if (eventAnnotationsChanged || uttMetadataChanged || searchTermChanged) {
this.debouncedLoadData();
}
}
debouncedLoadData = debounce(() => this.loadData(), 700);
loadData = () => {
const { eventAnnotations, requestUttTaxonomy, uttMetadata } = this.props;
const { searchTerm } = this.state;
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
const withEntities = uttEntityIds.concat(eventAnnotations).join(',');
requestUttTaxonomy({ searchTerm, withEntities });
};
onKeyUp = event => this.setState({ searchTerm: event.target.value });
isInTaxonomy = id => this.getUttName(id) !== id;
getUttName = id => {
const { getUttName, uttTaxonomy } = this.props;
return getUttName(uttTaxonomy, id);
};
renderOption = id => {
const { renderOption, uttTaxonomy } = this.props;
return this.isInTaxonomy(id) ? renderOption(uttTaxonomy, id) : <React.Fragment />;
};
getOptions() {
const { uttMetadata, uttTaxonomy } = this.props;
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
return Immutable.List(uttTaxonomy?.uttNameById?.keys()).filterNot(uttEntityIds.contains);
}
render() {
const { onAddCategory, uttTaxonomyLoading } = this.props;
const { transport } = this.state;
return (
<SimpleSelectInput
getOptionId={identity}
getOptionName={id => this.getUttName(id)}
isLoading={uttTaxonomyLoading}
onChange={onAddCategory}
onKeyUp={this.onKeyUp}
options={this.getOptions()}
optionsSort={options =>
options.sort((a, b) => this.getUttName(a).localeCompare(this.getUttName(b)))
}
placeholder="Start typing to search for your Topic..."
renderOption={this.renderOption}
transport={transport}
/>
);
}
}
const mapStateToProps = createStructuredSelector({
eventAnnotations: EventSelectors.selectEventAnnotations,
uttTaxonomy: UttTaxonomySelectors.selectUttTaxonomy,
uttTaxonomyLoading: UttTaxonomySelectors.uttTaxonomyLoading,
});
const mapDispatchToProps = {
requestUttTaxonomy: UttTaxonomyActions.requestUttTaxonomy,
};
export default connect(mapStateToProps, mapDispatchToProps)(CategoryTypeahead);
```
</details>
## Refactor to use Hooks
### Part 1
Pretty much just convert the class to a functional component and use hooks
The benefits don't appear obvious — or at least seem a little arbitrary
- Readability is a _little_ better, at least in terms of number of lines...
- Testing [a single aspect] is still convoluted
*️⃣ The HoC `connect` is still being used (more on that later)
<details>
<summary>Code</summary>
```jsx
import Immutable from 'immutable';
import PropTypes from 'prop-types';
import { identity } from 'ramda';
import React from 'react';
import IPropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { useDebounce } from 'react-use';
import { createStructuredSelector } from 'reselect';
import * as EventSelectors from 'state/events/selectors';
import * as UttTaxonomyActions from 'state/utt/actions';
import * as UttTaxonomySelectors from 'state/utt/selectors';
import InternalProps from 'modules/internal-props';
import { SimpleSelectInput, SimpleTypeaheadTransport } from 'components/Inputs/Select';
import { UttMetadata } from 'models/events';
const CategoryTypeahead = ({
eventAnnotations,
getUttName,
onAddCategory,
renderOption,
requestUttTaxonomy,
uttMetadata,
uttTaxonomy,
uttTaxonomyLoading,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [transport] = useState(() => new SimpleTypeaheadTransport());
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
const withEntities = uttEntityIds.concat(eventAnnotations).join(',');
const [, cancel] = useDebounce(
() =>
requestUttTaxonomy({
searchTerm,
withEntities,
}),
700,
[requestUttTaxonomy, searchTerm, withEntities],
);
// cancel the debounce if component unmounts
useEffect(() => () => cancel, [cancel]);
const handleSearch = useCallback(evt => setSearchTerm(evt.target.value), []);
const getUttNameById = useCallback(id => getUttName(uttTaxonomy, id), [getUttName, uttTaxonomy]);
const sortOptions = useCallback(
options => options.sort((a, b) => getUttNameById(a).localeCompare(getUttNameById(b))),
[getUttNameById],
);
const options = useMemo(
() => Immutable.List(uttTaxonomy?.uttNameById?.keys()).filterNot(uttEntityIds.contains),
[uttEntityIds, uttTaxonomy],
);
return (
<SimpleSelectInput
getOptionId={identity}
getOptionName={getUttNameById}
isLoading={uttTaxonomyLoading}
onChange={onAddCategory}
onKeyUp={handleSearch}
options={options}
optionsSort={sortOptions}
placeholder="Start typing to search for your Topic..."
renderOption={id =>
getUttNameById(id) !== id ? renderOption(uttTaxonomy, id) : <React.Fragment />
}
transport={transport}
/>
);
};
CategoryTypeahead.propTypes = {
eventAnnotations: IPropTypes.listOf(PropTypes.string),
getUttName: PropTypes.func,
onAddCategory: PropTypes.func,
renderOption: PropTypes.func,
requestUttTaxonomy: PropTypes.func,
uttMetadata: InternalProps.UttMetadata,
uttTaxonomy: InternalProps.UttTaxonomy,
uttTaxonomyLoading: PropTypes.bool,
};
CategoryTypeahead.defaultProps = {
uttMetadata: new UttMetadata(),
};
const mapStateToProps = createStructuredSelector({
eventAnnotations: EventSelectors.selectEventAnnotations,
uttTaxonomy: UttTaxonomySelectors.selectUttTaxonomy,
uttTaxonomyLoading: UttTaxonomySelectors.uttTaxonomyLoading,
});
const mapDispatchToProps = {
requestUttTaxonomy: UttTaxonomyActions.requestUttTaxonomy,
};
export default connect(mapStateToProps, mapDispatchToProps)(CategoryTypeahead);
```
</details>
### Part 2
Let's **group** functionality and create a _new_ hook
- `searchTerm`, the keyUp handler (i.e. "onSearch"), and the debounce are functionaly interconnected
- Abstract this likeness into a custom hook called `useSearch`
<details>
<summary>Code</summary>
```jsx
const useSearch = ({ requestUttTaxonomy, eventAnnotations, uttEntityIds }) => {
const [searchTerm, setSearchTerm] = useState('');
const withEntities = uttEntityIds.concat(eventAnnotations).join(',');
const [, cancel] = useDebounce(
() =>
requestUttTaxonomy({
searchTerm,
withEntities,
}),
700,
[requestUttTaxonomy, searchTerm, withEntities],
);
// cancel the debounce if component unmounts
useEffect(() => () => cancel, [cancel]);
return useCallback(evt => setSearchTerm(evt.target.value), []);
};
```
```diff
- const [searchTerm, setSearchTerm] = useState('');
const [transport] = useState(() => new SimpleTypeaheadTransport());
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
- const withEntities = uttEntityIds.concat(eventAnnotations).join(',');
-
- const [, cancel] = useDebounce(
- () =>
- requestUttTaxonomy({
- searchTerm,
- withEntities,
- }),
- 700,
- [requestUttTaxonomy, searchTerm, withEntities],
- );
-
- // cancel the debounce if component unmounts
- useEffect(() => () => cancel, [cancel]);
-
- const handleSearch = useCallback(evt => setSearchTerm(evt.target.value), []);
+ const handleSearch = useSearch({ requestUttTaxonomy, eventAnnotations, uttEntityIds });
```
</details>
### Part 3
The same can be done with _options_
<details>
<summary>Code</summary>
```jsx
const useOptions = ({ getUttNameById, uttEntityIds, uttTaxonomy }) => {
const sortOptions = useCallback(
options => options.sort((a, b) => getUttNameById(a).localeCompare(getUttNameById(b))),
[getUttNameById],
);
const options = useMemo(
() => Immutable.List(uttTaxonomy?.uttNameById?.keys()).filterNot(uttEntityIds.contains),
[uttEntityIds, uttTaxonomy],
);
return { options, sortOptions };
};
```
```diff
const [transport] = useState(() => new SimpleTypeaheadTransport());
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
const handleSearch = useSearch({ requestUttTaxonomy, eventAnnotations, uttEntityIds });
const getUttNameById = useCallback(id => getUttName(uttTaxonomy, id), [getUttName, uttTaxonomy]);
-
- const sortOptions = useCallback(
- options => options.sort((a, b) => getUttNameById(a).localeCompare(getUttNameById(b))),
- [getUttNameById],
- );
-
- const options = useMemo(
- () => Immutable.List(uttTaxonomy?.uttNameById?.keys()).filterNot(uttEntityIds.contains),
- [uttEntityIds, uttTaxonomy],
- );
+ const { options, sortOptions } = useOptions({ getUttNameById, uttEntityIds, uttTaxonomy });
```
</details>
<br />
- Can `useOptions` be split further?
- What are the pros and cons of doing that?
### Part 4
The HoC from `react-redux` can be replaced with hooks too
- `useDispatch` and `useSelector`
- This simplifies the arguments for `useSearch` because it can use `useDispatch` internally
<details>
<summary>Code</summary>
```diff
-const useSearch = ({ requestUttTaxonomy, eventAnnotations, uttEntityIds }) => {
+const useSearch = uttEntityIds => {
const [searchTerm, setSearchTerm] = useState('');
+ const eventAnnotations = useSelector(EventSelectors.selectEventAnnotations);
const withEntities = uttEntityIds.concat(eventAnnotations).join(',');
+ const dispatch = useDispatch();
const [, cancel] = useDebounce(
() =>
- requestUttTaxonomy({
- searchTerm,
- withEntities,
- }),
+ dispatch(
+ UttTaxonomyActions.requestUttTaxonomy({
+ searchTerm,
+ withEntities,
+ }),
+ ),
700,
- [requestUttTaxonomy, searchTerm, withEntities],
+ [dispatch, searchTerm, withEntities],
);
```
```diff
-const CategoryTypeahead = ({
- eventAnnotations,
- getUttName,
- onAddCategory,
- renderOption,
- requestUttTaxonomy,
- uttMetadata,
- uttTaxonomy,
- uttTaxonomyLoading,
-}) => {
+const CategoryTypeahead = ({ getUttName, onAddCategory, renderOption, uttMetadata }) => {
+ const uttTaxonomy = useSelector(UttTaxonomySelectors.selectUttTaxonomy);
+ const uttTaxonomyLoading = useSelector(UttTaxonomySelectors.uttTaxonomyLoading);
+
const [transport] = useState(() => new SimpleTypeaheadTransport());
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
- const handleSearch = useSearch({ requestUttTaxonomy, eventAnnotations, uttEntityIds });
+ const handleSearch = useSearch(uttEntityIds);
```
```diff
-const mapStateToProps = createStructuredSelector({
- eventAnnotations: EventSelectors.selectEventAnnotations,
- uttTaxonomy: UttTaxonomySelectors.selectUttTaxonomy,
- uttTaxonomyLoading: UttTaxonomySelectors.uttTaxonomyLoading,
-});
-
-const mapDispatchToProps = {
- requestUttTaxonomy: UttTaxonomyActions.requestUttTaxonomy,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(CategoryTypeahead);
+export default CategoryTypeahead;
```
</details>
## End Result
```jsx
import Immutable from 'immutable';
import PropTypes from 'prop-types';
import { identity } from 'ramda';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDebounce } from 'react-use';
import * as EventSelectors from 'state/events/selectors';
import * as UttTaxonomyActions from 'state/utt/actions';
import * as UttTaxonomySelectors from 'state/utt/selectors';
import InternalProps from 'modules/internal-props';
import { SimpleSelectInput, SimpleTypeaheadTransport } from 'components/Inputs/Select';
import { UttMetadata } from 'models/events';
const useOptions = ({ getUttNameById, uttEntityIds, uttTaxonomy }) => {
const sortOptions = useCallback(
options => options.sort((a, b) => getUttNameById(a).localeCompare(getUttNameById(b))),
[getUttNameById],
);
const options = useMemo(
() => Immutable.List(uttTaxonomy?.uttNameById?.keys()).filterNot(uttEntityIds.contains),
[uttEntityIds, uttTaxonomy],
);
return { options, sortOptions };
};
const useSearch = uttEntityIds => {
const [searchTerm, setSearchTerm] = useState('');
const eventAnnotations = useSelector(EventSelectors.selectEventAnnotations);
const withEntities = uttEntityIds.concat(eventAnnotations).join(',');
const dispatch = useDispatch();
const [, cancel] = useDebounce(
() =>
dispatch(
UttTaxonomyActions.requestUttTaxonomy({
searchTerm,
withEntities,
}),
),
700,
[dispatch, searchTerm, withEntities],
);
// cancel the debounce if component unmounts
useEffect(() => () => cancel, [cancel]);
return useCallback(evt => setSearchTerm(evt.target.value), []);
};
const CategoryTypeahead = ({ getUttName, onAddCategory, renderOption, uttMetadata }) => {
const uttTaxonomy = useSelector(UttTaxonomySelectors.selectUttTaxonomy);
const uttTaxonomyLoading = useSelector(UttTaxonomySelectors.uttTaxonomyLoading);
const [transport] = useState(() => new SimpleTypeaheadTransport());
const uttEntityIds = uttMetadata.uttEntityIds || Immutable.List();
const handleSearch = useSearch(uttEntityIds);
const getUttNameById = useCallback(id => getUttName(uttTaxonomy, id), [getUttName, uttTaxonomy]);
const { options, sortOptions } = useOptions({ getUttNameById, uttEntityIds, uttTaxonomy });
return (
<SimpleSelectInput
getOptionId={identity}
getOptionName={getUttNameById}
isLoading={uttTaxonomyLoading}
onChange={onAddCategory}
onKeyUp={handleSearch}
options={options}
optionsSort={sortOptions}
placeholder="Start typing to search for your Topic..."
renderOption={id =>
getUttNameById(id) !== id ? renderOption(uttTaxonomy, id) : <React.Fragment />
}
transport={transport}
/>
);
};
CategoryTypeahead.propTypes = {
getUttName: PropTypes.func,
onAddCategory: PropTypes.func,
renderOption: PropTypes.func,
uttMetadata: InternalProps.UttMetadata,
};
CategoryTypeahead.defaultProps = {
uttMetadata: new UttMetadata(),
};
export default CategoryTypeahead;
```