# React Hooks Example
Context + useReducer
## Original
Take the following (real world) class component `MultiLocaleInput` that needs to be _composed_ for a variant use-case
```jsx
import classNames from 'classnames';
import Immutable from 'immutable';
import IPropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import CountryInput from 'components/Inputs/Select/Country';
import InternalProps from 'modules/internal-props';
import LanguageInput from 'components/Inputs/Select/Language';
import { Button } from 'modules/feather';
import { Locale } from 'models/localization';
import * as CountrySelectors from 'state/countries/selectors';
import * as LanguageSelectors from 'state/languages/selectors';
import styles from './styles.css';
import { filterExistingLocales } from './utils';
class MultiLocaleInput extends React.Component {
static propTypes = {
canOverrideAll: PropTypes.bool,
countries: IPropTypes.mapOf(InternalProps.Country),
displayedLocales: IPropTypes.listOf(InternalProps.Locale),
initialSelectedLocales: IPropTypes.listOf(InternalProps.Locale),
languages: IPropTypes.mapOf(InternalProps.Language),
searchDisabled: PropTypes.bool,
title: PropTypes.string,
updateLocales: PropTypes.func,
};
constructor(props) {
super(props);
this.state = {
country: undefined,
language: undefined,
selectedLocales: props.initialSelectedLocales || props.displayedLocales,
};
}
componentDidUpdate(_, prevState) {
const { updateLocales } = this.props;
const { selectedLocales } = this.state;
if (selectedLocales !== prevState.selectedLocales) {
updateLocales(selectedLocales);
}
}
addLocaleFromInputs = () => {
const { country, language } = this.state;
this.addLocale(country, language);
};
addLocaleFromToken = locale => {
const { country, language } = locale;
this.addLocale(country, language);
};
addLocale = (country, language) => {
const { canOverrideAll } = this.props;
const { selectedLocales } = this.state;
const locale = new Locale({ country, language });
let updatedLocales = filterExistingLocales(
country,
language,
canOverrideAll,
selectedLocales,
).push(locale);
// Leave language to make selecting multiple countries easier.
this.setState({
country: undefined,
selectedLocales: updatedLocales,
});
};
canAddLocale = () => {
const { language } = this.state;
return !!language && !this.localeExists();
};
onFieldChanged = (key, selectedItem) => {
const stateField = { [key]: selectedItem };
this.setState({
...stateField,
});
};
localeExists = () => {
const { country, language } = this.state;
const { displayedLocales } = this.props;
return displayedLocales.includes(existingLocale => {
const countryMatch = !existingLocale.country || existingLocale.country === country;
const languageMatch = !existingLocale.language || existingLocale.language === language;
const countryAll = existingLocale.country === undefined;
const languageAll = existingLocale.language === undefined;
return (
(countryMatch && languageMatch) ||
(languageMatch && countryAll) ||
(countryMatch && languageAll) ||
(languageAll && countryAll)
);
});
};
removeLocale = locale => {
const { country, language } = locale;
const { selectedLocales } = this.state;
const updatedLocales = selectedLocales.filter(existingLocale => {
return !(existingLocale.country === country && existingLocale.language === language);
});
this.setState({ selectedLocales: updatedLocales });
};
addLocalesByLang = language => {
const { displayedLocales } = this.props;
const { selectedLocales } = this.state;
const localesForLang = displayedLocales.filter(
existingLocale => existingLocale.language === language,
);
if (!!localesForLang) {
const updatedLocales = selectedLocales.concat(localesForLang);
this.setState({ selectedLocales: updatedLocales });
}
};
removeLocalesByLang = language => {
const { selectedLocales } = this.state;
const updatedLocales = selectedLocales.filter(
existingLocale => existingLocale.language !== language,
);
this.setState({ selectedLocales: updatedLocales });
};
containsOnlyAllLocale(displayedLocales) {
if (displayedLocales.size !== 1) {
return false;
}
const onlyLocale = displayedLocales.first();
return onlyLocale.language === undefined && onlyLocale.country === undefined;
}
isLanguageSelected(language) {
const { displayedLocales } = this.props;
const { selectedLocales } = this.state;
const localesForLang = displayedLocales.filter(
existingLocale => existingLocale.language === language,
);
return localesForLang.some(existingLocale => selectedLocales.includes(existingLocale));
}
renderLocaleGroups = () => {
const { countries, languages, displayedLocales } = this.props;
const { selectedLocales } = this.state;
if (this.containsOnlyAllLocale(displayedLocales)) {
return null;
}
const languageToLocaleMap = displayedLocales.reduce((languageLocaleMap, localeItem) => {
const associatedLanguages = languageLocaleMap.get(localeItem.language, Immutable.List());
return languageLocaleMap.set(localeItem.language, associatedLanguages.push(localeItem));
}, Immutable.Map());
return (
<table className={styles.localesTable}>
<tbody>
{languageToLocaleMap
.map((locales, language) => {
const hydratedLanguage = languages.get(language);
const isLanguageSelected = this.isLanguageSelected(language);
return (
<tr className={styles.localesTableRow} key={language || 'All'}>
<td className={styles.languageContainer}>
<LanguageToken
hydratedLanguage={hydratedLanguage}
onAdd={this.addLocalesByLang}
onRemove={this.removeLocalesByLang}
selected={isLanguageSelected}
/>
</td>
<td className={styles.countriesContainer}>
{locales.map(locale => {
const hydratedCountry = countries.get(locale.country);
const isLocaleSelected = selectedLocales.includes(locale);
return (
<CountryToken
countryName={hydratedCountry?.name || 'All'}
selected={isLocaleSelected}
key={locale.toString()}
locale={locale}
onRemove={this.removeLocale}
onAdd={this.addLocaleFromToken}
/>
);
})}
</td>
</tr>
);
})
.toList()}
</tbody>
</table>
);
};
render() {
const { displayedLocales, searchDisabled, title } = this.props;
const { country, language } = this.state;
const localesContainerClassNames = classNames({
[styles.localesContainerError]: !displayedLocales.size > 0,
});
return (
<div className={localesContainerClassNames}>
{!searchDisabled && (
<React.Fragment>
<div className={styles.localesLabel}>{title}</div>
<div className={styles.localesContainer}>
<div className={styles.localesFieldContainer}>
<div>Language</div>
<LanguageInput
includeAll
onChange={this.onFieldChanged.bind(this, 'language')}
priorityLanguages={['ja', 'es', 'ar', 'pt']}
value={language}
/>
</div>
<div className={styles.localesFieldContainer}>
<div>Country</div>
<div className="countryContainer">
<CountryInput
disabled={!language}
includeAll
onChange={this.onFieldChanged.bind(this, 'country')}
value={country}
/>
</div>
</div>
<Button
className={styles.addLocale}
disabled={!this.canAddLocale()}
onClick={this.addLocaleFromInputs}
>
Add Locale
</Button>
</div>
</React.Fragment>
)}
{this.renderLocaleGroups()}
</div>
);
}
}
const mapStateToProps = createStructuredSelector({
countries: CountrySelectors.selectAll,
languages: LanguageSelectors.selectAll,
});
export default connect(mapStateToProps)(MultiLocaleInput);
```
Consider the following questions:
1. Does that feel like a lot to take in?
2. How long does it take you to figure out everything that this component is doing?
3. How long do you estimate it would take you to extend this component?
My personal answers were:
1. Yes
2. A while
3. I don’t feel confident determining an accurate answer without really digging in first
Now consider how you would start to separate the state/business logic from the visual component. What APIs or patterns would you use?
Let’s analyze how we can achieve this using React Context and Hooks.
## Step 1
Move state and business logic into a [“Duck”](https://github.com/erikras/ducks-modular-redux) (i.e. a reducer with action creators… just like Redux)
```jsx
import { Locale } from 'models/localization';
import { filterExistingLocales } from './utils';
const ADD_LOCALE = 'ADD_LOCALE';
const ADD_LOCALES_BY_LANG = 'ADD_LOCALES_BY_LANG';
const COUNTRY_CONTROL_CHANGE = 'COUNTRY_CONTROL_CHANGE';
const LANGUAGE_CONTROL_CHANGE = 'LANGUAGE_CONTROL_CHANGE';
const REMOVE_LOCALE = 'REMOVE_LOCALE';
const REMOVE_LOCALES_BY_LANG = 'REMOVE_LOCALES_BY_LANG';
export const addLocale = (locale, canOverrideAll) => ({
type: ADD_LOCALE,
payload: { locale, canOverrideAll },
});
export const addLocalesByLang = language => ({
type: ADD_LOCALES_BY_LANG,
payload: language,
});
export const countryControlChange = country => ({
type: COUNTRY_CONTROL_CHANGE,
payload: country,
});
export const languageControlChange = language => ({
type: LANGUAGE_CONTROL_CHANGE,
payload: language,
});
export const removeLocale = locale => ({
type: REMOVE_LOCALE,
payload: locale,
});
export const removeLocalesByLang = language => ({
type: REMOVE_LOCALES_BY_LANG,
payload: language,
});
// biz logic
const canAddLocale = ({ displayedLocales, countryControlValue, languageControlValue }) => {
if (!state.languageControlValue) {
return false;
}
return displayedLocales.includes(locale => {
const countryMatch = !locale.country || locale.country === countryControlValue;
const languageMatch = !locale.language || locale.language === languageControlValue;
const countryAll = locale.country === undefined;
const languageAll = locale.language === undefined;
return (
(countryMatch && languageMatch) ||
(languageMatch && countryAll) ||
(countryMatch && languageAll) ||
(languageAll && countryAll)
);
});
};
// biz logic
const containsOnlyAllLocale = locales => {
if (locales.size !== 1) {
return false;
}
const locale = locales.first();
return locale.language === undefined && locale.country === undefined;
};
// used to create _initial state_
// https://reactjs.org/docs/hooks-reference.html#lazy-initialization
export const init = ({ displayedLocales, selectedLocales }) => {
const countryControlValue = undefined;
const languageControlValue = undefined;
return {
canAddLocale: canAddLocale({ displayedLocales, countryControlValue, languageControlValue }),
containsOnlyAllLocale: containsOnlyAllLocale(displayedLocales),
countryControlValue,
displayedLocales,
hasError: displayedLocales.size === 0,
languageControlValue,
languageToLocaleMap: displayedLocales.groupBy(locale => locale.language),
selectedLocales,
};
};
export const reducer = (state, action) => {
switch (action.type) {
case ADD_LOCALE: {
const { locale, canOverrideAll } = action.payload;
const { country, language } = locale;
return {
...state,
countryControlValue: undefined,
// Leave language to make selecting multiple countries easier.
selectedLocales: filterExistingLocales(
country,
language,
canOverrideAll,
state.selectedLocales,
).push(new Locale({ country, language })),
};
}
case ADD_LOCALES_BY_LANG: {
const localesForLang = state.displayedLocales.filter(
locale => locale.language === action.payload,
);
return localesForLang.length === 0
? state
: {
...state,
selectedLocales: state.selectedLocales.concat(localesForLang),
};
}
case COUNTRY_CONTROL_CHANGE: {
const countryControlValue = action.payload;
return {
...state,
canAddLocale: canAddLocale({
displayedLocales: state.displayedLocales,
countryControlValue,
languageControlValue: state.languageControlValue,
}),
countryControlValue,
};
}
case LANGUAGE_CONTROL_CHANGE: {
const languageControlValue = action.payload;
return {
...state,
canAddLocale: canAddLocale({
displayedLocales: state.displayedLocales,
countryControlValue: state.countryControlValue,
languageControlValue,
}),
languageControlValue,
};
}
case REMOVE_LOCALE: {
const { country, language } = action.payload;
return {
...state,
selectedLocales: state.selectedLocales.filter(
locale => locale.country !== country || locale.language !== language,
),
};
}
case REMOVE_LOCALES_BY_LANG: {
return {
...state,
selectedLocales: state.selectedLocales.filter(locale => locale.language !== action.payload),
};
}
default: {
throw new Error(`action ${action.type} not found`);
}
}
};
```
The main goal here is to write very **familiar code**, especially considering the pre-existing use of Redux.
> For the sake of this example, put this in a new file named `duck.js`.
## Step 2
Create a _context provider_ and a hook to consume the context.
> For the sake of this example, put the following code in a file named `context.js`.
```jsx
import PropTypes from 'prop-types';
import React from 'react';
import IPropTypes from 'react-immutable-proptypes';
import InternalProps from 'modules/internal-props';
import * as duck from './duck';
export const MultiLocaleInputContext = React.createContext(null);
export const useMultiLocaleInputContext = () => React.useContext(MultiLocaleInputContext);
export const MultiLocaleInputProvider = ({
children,
displayedLocales,
initialSelectedLocales,
}) => {
const [state, dispatch] = React.useReducer(
duck.reducer,
{
displayedLocales,
selectedLocales: initialSelectedLocales ?? displayedLocales,
},
duck.init,
);
const context = React.useMemo(() => ({ dispatch, state }), [state]);
return (
<MultiLocaleInputContext.Provider value={context}>{children}</MultiLocaleInputContext.Provider>
);
};
MultiLocaleInputProvider.propTypes = {
children: PropTypes.node,
displayedLocales: IPropTypes.listOf(InternalProps.Locale),
initialSelectedLocales: IPropTypes.listOf(InternalProps.Locale),
};
```
## Step 3
Create a _base_ MultiLocaleInput component that consumes the context
```jsx
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import CountryInput from 'components/Inputs/Select/Country';
import LanguageInput from 'components/Inputs/Select/Language';
import { Button } from 'modules/feather';
import * as CountrySelectors from 'state/countries/selectors';
import * as LanguageSelectors from 'state/languages/selectors';
import { useMultiLocaleInputContext } from './context';
import * as duck from './duck';
import styles from './styles.css';
export const MultiLocaleInputBase = ({ canOverrideAll, searchDisabled, title, updateLocales }) => {
const countries = useSelector(CountrySelectors.selectAll);
const languages = useSelector(LanguageSelectors.selectAll);
// This component's state, using `useReducer`, is simply kept here
const { dispatch, state } = useMultiLocaleInputContext();
/**
* Shim for `componentDidUpdate`.
* This logic should _really_ just be encapsulated in an abstracted `setSelectedLocales` that
* also handles the side-effect.
*/
const prevSelectedLocales = useRef(state.selectedLocales);
useEffect(() => {
if (state.selectedLocales !== prevSelectedLocales.current) {
updateLocales(state.selectedLocales);
prevSelectedLocales.current = state.selectedLocales;
}
}, [state.selectedLocales, updateLocales]);
return (
<section
className={classNames({
[styles.localesContainerError]: state.hasError,
})}
>
{!searchDisabled && (
<>
<h1 className={styles.localesLabel}>{title}</h1>
<div className={styles.localesContainer}>
<div className={styles.localesFieldContainer}>
<label>Language</label>
<LanguageInput
includeAll
onChange={language => dispatch(duck.languageControlChange(language))}
priorityLanguages={['ja', 'es', 'ar', 'pt']}
value={state.languageControlValue}
/>
</div>
<div className={styles.localesFieldContainer}>
<label>Country</label>
<div className="countryContainer">
<CountryInput
disabled={!state.languageControlValue}
includeAll
onChange={country => dispatch(duck.countryControlChange(country))}
value={state.countryControlValue}
/>
</div>
</div>
<Button
className={styles.addLocale}
disabled={!state.canAddLocale}
onClick={() =>
dispatch(
duck.addLocale(
{
country: state.countryControlValue,
language: state.languageControlValue,
},
canOverrideAll,
),
)
}
>
Add Locale
</Button>
</div>
</>
)}
{!state.containsOnlyAllLocale && (
<table className={styles.localesTable}>
<tbody>
{state.languageToLocaleMap
.map((locales, language) => (
<tr className={styles.localesTableRow} key={language || 'All'}>
<td className={styles.languageContainer}>
<LanguageToken
hydratedLanguage={languages.get(language)}
onAdd={languageToken => dispatch(duck.addLocalesByLang(languageToken))}
onRemove={languageToken => dispatch(duck.removeLocalesByLang(languageToken))}
selected={isLanguageSelected(language)}
/>
</td>
<td className={styles.countriesContainer}>
{locales.map(locale => (
<CountryToken
countryName={countries.get(locale.country)?.name || 'All'}
selected={selectedLocales.includes(locale)}
key={locale.toString()}
locale={locale}
onRemove={localeToken => dispatch(duck.removeLocale(localeToken))}
onAdd={localeToken => dispatch(duck.addLocale(localeToken, canOverrideAll))}
/>
))}
</td>
</tr>
))
.toList()}
</tbody>
</table>
)}
</section>
);
};
MultiLocaleInputBase.propTypes = {
canOverrideAll: PropTypes.bool,
searchDisabled: PropTypes.bool,
title: PropTypes.string,
updateLocales: PropTypes.func,
};
```
## Step 4
Finally, compose these pieces to create variants of `MultiLocaleInput`!
Our existing `MultiLocaleInput` looks likes this:
```jsx
import MultiLocaleInputBase from './MultiLocaleInput';
import { MultiLocaleInputProvider } from './context';
export const MultiLocaleInput = ({ displayedLocales, initialSelectedLocales, ...props }) => {
return (
<MultiLocaleInputProvider
displayedLocales={displayedLocales}
initialSelectedLocales={initialSelectedLocales}
>
<MultiLocaleInputBase {...props} />
</MultiLocaleInputProvider>
);
};
MultiLocaleInput.propTypes = {
displayedLocales: IPropTypes.listOf(InternalProps.Locale),
initialSelectedLocales: IPropTypes.listOf(InternalProps.Locale),
};
```
An example variation could look like this:
```jsx
import { MultiLocaleInputProvider } from './context';
import { SomeOtherVisualVariant } from './variants';
export const OtherMultiLocaleInput = ({ displayedLocales, initialSelectedLocales, ...props }) => {
return (
<MultiLocaleInputProvider
displayedLocales={displayedLocales}
initialSelectedLocales={initialSelectedLocales}
>
<SomeOtherVisualVariant {...props} />
</MultiLocaleInputProvider>
);
};
OtherMultiLocaleInput.propTypes = {
displayedLocales: IPropTypes.listOf(InternalProps.Locale),
initialSelectedLocales: IPropTypes.listOf(InternalProps.Locale),
};
```