Advanced React Patterns
Context Module Functions
Context module functions are basically helper functions you build to provide a more clean API for users who consume your custom contexts.
For example, suppose you have a CounterProvider
where a common use case is to increment
and decrement
the count
inside of that context.
You could write context module functions like this...
const CounterContext = React.createContext();
const CounterProvider = ({ initialCount = 0, ...props }) => {
const [count, dispatch] = React.useReducer((state, action) => {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
}
}, initialCount);
// Context module functions!
const increment = () => dispatch({ type: 'increment' });
const decrement = () => dispatch({ type: 'decrement ' });
const value = { count, increment, decrement };
return <CounterContext.Provider value={value} {...props} />;
};
const MyCount = () => {
const { count, increment } = React.useContext(CounterContext);
return (
<>
<p>{count}</p>
<button onClick={increment}>increment</button>
</>
);
};
However, there are few limitations with the above approach:
You can't place
increment
anddecrement
into dependency lists foruseEffect
oruseCallback
unless you memoize them by wrapping them inuseCallback
. Otherwise, the function reference will change on every re-render, leading to unintended side effects.Because the context module functions live inside of
CounterProvider
(or in auseCounter
hook you could create), you can't tree shake, code split, or lazily load them.
To solve this problem, your context module functions can accept dispatch
as as an argument, so it can live outside of your React components.
const CounterContext = React.createContext();
const CounterProvider = ({ initialCount = 0, ...props }) => {
const [count, dispatch] = React.useReducer((state, action) => {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
}
}, initialCount);
const value = [count, dispatch];
return <CounterContext.Provider value={value} {...props} />;
};
// Context module functions!
const increment = dispatch => dispatch({ type: 'increment' });
const decrement = dispatch => dispatch({ type: 'decrement' });
const MyCount = () => {
const [count, dispatch] = React.useContext(CounterContext);
return (
<>
<p>{count}</p>
<button onClick={() => increment(dispatch)}>increment</button>
</>
);
};
In this pattern, you continue to give the user direct access to dispatch
in context. However, to make their lives easier, you provide them with common operations in the form of context module functions.
These functions accept dispatch
as an argument, so they can interact with context without being directly involved with it (like defined inside of a provider or custom hook).
In doing so, these helper functions don't have to worry about memoization, and they can be easily tree shaken, code split, and lazily loaded as well!
Compound Components
Compound components are components that work together to form a complete UI. In HTML, think of the combination of select
and option
:
<select>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
select
and option
can't be used separately, but together they form a dropdown input.
More specifically, select
handles the management of state of the UI, while option
handles configuration for how the select should operate (its options and their values).
A naive React implementation
In React world, if we wanted to create a select
and option
, it's common to create a CustomSelect
with an options
prop:
// This component would implement `select` and `option`s behind the scenes
<CustomSelect
options={[
{ value: '1', display: 'Option 1' },
{ value: '2', display: 'Option 2' },
]}
/>
The trouble comes when we want to extend our CustomSelect
. Maybe we need to add additional attributes to the option
s rendered. Or maybe we need the display
to change style based on whether it's selected.
We could add to the API surface area by introducing more props, but that just means more to code and more for users to learn! (This could blow up and get very messy in a real-world application.)
Compound components as a solution
Suppose we want to create a Toggle
that contains a toggle button and that shows different content when the toggle is on vs. when it's off.
In a compound components approach, it would look something like this:
<Toggle>
<ToggleOn>Content that appears when toggle is on</ToggleOn>
<ToggleOff>Content that appears when toggle is off</ToggleOff>
<ToggleButton />
</Toggle>
Toggle
should manage the state (just like select
does). But how do we work with that state inside of ToggleOn
, ToggleOff
, and ToggleButton
? From the perspective of the user, we don't see any of the state sharing.
Answer: You can implicitly pass props to the children of Toggle
using a combination of React.Children.map
and React.cloneElement
.
const Toggle = ({ children }) => {
// Internal state management
const [on, setOn] = React.useState(false);
const toggle = () => setOn(!on);
return React.Children.map(children, child => {
// Built-in DOM components
if (typeof child.type === 'string') return child;
// Custom composite components
return React.cloneElement(child, { on, toggle });
});
};
const ToggleOn = ({ on, children }) => (on ? children : null);
const ToggleOff = ({ on, children }) => (!on ? children : null);
const ToggleButton = ({ on, toggle }) => <Switch on={on} onClick={toggle} />;
There's a few things going on in the code above:
Toggle
manages theon
state and creates a helper functiontoggle
for switching that state.We map over
children
usingReact.Children.map
. (This is required when mapping over React components. A simpleArray.map
wouldn't do.)If the child is a DOM component like
<div />
or<span />
, we just return it.Otherwise, we clone the child with
React.cloneElement
and pass along the internal state ofToggle
to the child in the form of props.Finally, the child components just access the props
on
andtoggle
that we passed like normal.
Pro tip: One concern with this approach is that a user could create a CustomToggleButton
that accepts on
and toggle
props, and Toggle
will pass those props along. If you want to stop this, you can just create a allowedTypes
array to filter out components that you don't want to share internal state with.
const allowedTypes = [ToggleOn, ToggleOff, ToggleButton];
const Toggle = ({ children }) => {
return React.Children.map(children, child => {
// Don't pass along internal state to children that we don't allow
// Added benefit of this is it also handles the built-in DOM components too!
if (!allowedTypes.includes(child.type)) return child;
return React.cloneElement(child, { on, toggle });
});
};
Flexible Compound Components
What happens if you want to wrap your compound components inside your own custom parent components (like for style reasons)? In other words, how do you share state with compound components that are grandchildren?
<Toggle>
<ToggleOn>Content that appears when toggle is on</ToggleOn>
<ToggleOff>Content that appears when toggle is off</ToggleOff>
<div>
<ToggleButton />
</div>
</Toggle>
Answer: Use context!
const ToggleContext = React.createContext();
ToggleContext.displayName = 'ToggleContext';
function Toggle({ children }) {
const [on, setOn] = React.useState(false);
const toggle = () => setOn(!on);
return (
<ToggleContext.Provider value={{ on, toggle }}>
{children}
</ToggleContext.Provider>
);
}
const useToggle = () => {
const context = React.useContext(ToggleContext);
if (!context) {
throw new Error('You must wrap your components in <Toggle />');
}
return context;
};
// Children now access internal state using context (not props passing)!
function ToggleOn({ children }) {
const { on } = useToggle();
return on ? children : null;
}
function ToggleOff({ children }) {
const { on } = useToggle();
return on ? null : children;
}
function ToggleButton(props) {
const { on, toggle } = useToggle();
return <Switch on={on} onClick={toggle} {...props} />;
}
Now you can nest your Toggle
child components as deep as you want, and they should still work.
Prop Collections and Getters
Prop collections
Prop collections are basically just objects of props that you maintain for components that have a lot of props you need to keep track of. Common components that would benefit from prop collections are complex interactive elements like toggles or accordions. These components usually require props like onClick
, onKeyDown
, onFocus
,aria-pressed
, aria-expanded
, etc.
The basic idea with prop collections is that you pass them to the user, so they can spread the props over UI components. The benefit of this approach is that the user doesn't have to wire it all up themselves. Returning to the Toggle
case:
const useToggle = () => {
const [on, setOn] = React.useState(false);
const toggle = () => setOn(!on);
// We maintain a list of common props needed for the toggler
// This can expand as requirements get added
const togglerProps = {
'aria-pressed': on,
onClick: toggle,
};
return { on, toggle, togglerProps };
};
const App = () => {
const { togglerProps } = useToggle();
return (
<Toggle>
<ToggleOn />
<ToggleOff />
<button {...togglerProps}>Toggle</button>
</Toggle>
);
};
Prop getters
Just like prop collections, prop getters help you maintain a list of common props for your UI components. However, they are functions that return props, so you can allow your user to extend, customize, and combine props.
For example, suppose the user needs to trigger analytics when the toggle button is pressed.
const App = () => {
const { on, togglerProps } = useToggle();
return (
<Toggle>
<ToggleOn />
<ToggleOff />
<button {...togglerProps} onClick={triggerAnalytics}>
{on ? 'on' : 'off'}
</button>
</Toggle>
);
};
The above code overrides the onClick
that provides the toggle functionality, so the toggle stops working. We need a way of combining the built-in onClick
functionality with the custom onClick
analytics functionality. This is where prop getters comes in:
// Simple helper function for calling multiple functions at once
const callAll = (...fns) => (...args) =>
fns.forEach(fn => typeof fn === 'function' && fn(...args));
function useToggle() {
const [on, setOn] = React.useState(false);
const toggle = () => setOn(!on);
// This is the prop getter
const getTogglerProps = ({ onClick, ...props } = {}) => ({
'aria-pressed': on,
onClick: callAll(toggle, onClick),
...props,
});
return { on, toggle, getTogglerProps };
}
const App = () => {
const { on, getTogglerProps } = useToggle();
return (
<Toggle>
<ToggleOn />
<ToggleOff />
<button
{...getTogglerProps({
'aria-label': 'custom-button',
onClick: () => console.info('onButtonClick'),
id: 'custom-button-id',
})}
>
{on ? 'on' : 'off'}
</button>
</Toggle>
);
};
State Reducers and Inversion of Control
Suppose you have a complex input component like a Autocomplete
where you need to handle all kinds of interactions. State reducers are a way of managing all the state of these kinds of components using the reducer pattern via useReducer
.
const autocompleteReducer = (state, action) => {
switch (action.type) {
case 'change':
// Handle input change
case 'reset':
// Handle clearing input
case 'submit':
// Handle pressing enter
}
};
// This hook uses the state reducer to manage state
const useAutocomplete = () => {
const [state, dispatch] = useReducer(autocompleteReducer);
const onChange = value => dispatch({ type: 'change', payload: value });
const onReset = () => dispatch({ type: 'reset' });
const onSubmit = payload => dispatch({ type: 'submit', payload });
return {
state,
onChange,
onReset,
onSubmit,
};
};
Suppose someone else now consumes your Autocomplete
component. Sometimes their requirements can get very complex and layered, so how do we build an API that covers all these use cases?
Answer: You don't. It's near impossible to account for every possible use case. You can code for the most common use cases, but there are always going to be edge cases.
Solution: Instead, you implement inversion of control. This just means that you give the consumer back some control of the internals, so they can decide for themselves how to implement their unique requirements.
In practice, this means that you can give the user the option to pass their own reducer to useAutocomplete
, so they can code their own use cases:
const useAutocomplete = (reducer = autocompleteReducer) => {
const [state, dispatch] = useReducer(reducer);
// ...
return {
state,
onChange,
onReset,
onSubmit,
};
};
const App = () => {
const customAutocompleteReducer = (state, action) => {
// NEW REQUIREMENT: do not allow non-alphanumeric characters
if (action.type === 'change' && !isAlphanumeric(action.payload)) {
return state;
}
// Fall back to default logic otherwise
return autocompleteReducer(state, action);
};
const { state, onChange } = useAutocomplete(customAutocompleteReducer);
return <Input onChange={onChange} value={state.value} />;
};
There are a few things to note that are clever in this code snippet:
We default to
autocompleteReducer
if the user doesn't provide a custom reducer touseAutocomplete
because this helps cover the majority of times where the user doesn't need to make any customizations to our default logic.We expose the default
autocompleteReducer
to the user, so they can use it as the fallback in theircustomAutocompleteReducer
. The advantage of this is that the user doesn't have to build the reducer from the ground up. They can customize what they want and fall back to default logic for the rest.
Control Props
Last updated