# 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; ```