An intro to the useComponent pattern
Dan Hammer
Posted on April 14, 2021
You might be familiar with the "useComponent" pattern. I was first introduced to it by this fantastic write-up by Andrew Petersen.
I have found this pattern to be useful in many cases, and thought a basic introduction might be helpful. In this post, I will briefly break down the below simple example of this pattern:
import React, { useState } from "react";
const useRadioButtons = ({ options = [], onChange = () => {}} = {}) => {
const [selectedOption, setSelectedOption] = useState(options[0]);
const onChangeOption = (event) => {
setSelectedOption(event.target.value);
onChange(event.target.value);
};
return {
props: {
options,
onChangeOption,
selectedOption,
},
RadioButtons,
selectedOption,
};
};
const RadioButtons = ({ options, onChangeOption, selectedOption }) => {
return (
<div>
{options.map((option) => (
<>
<input
checked={selectedOption === option}
onChange={onChangeOption}
type="radio"
value={option}
/>
{option}
</>
))}
</div>
);
};
export default useRadioButtons;
import React from "react";
import useRadioButtons from "./use-radio-buttons";
function Foo() {
const { props, RadioButtons, selectedOption } = useRadioButtons({
options: [`A`, `B`, `C`],
});
return (
<>
<RadioButtons {...props} />
{selectedOption}
</>
);
}
export default Foo;
What is it?
The RadioButtons component itself is fairly simple. It accepts an array of options, an onChange function, and the current value and returns radio buttons with all of the options.
useRadioButtons, on the other hand, is where things get interesting.
The purpose of this pattern is to contain all of the state, function that rely on state, and hooks (especially any useEffects that specifically belong to the component) so that it's not necessary to set all of it up in the parent.
useRadioButtons sets up the useState for the selectedOption and the onChangeOption function. It returns a props object that can be passed directly into RadioButtons as well as the selectedOption to be used by the parent, and the RadioButtons function.
Less plumbing in the parent
const { props, RadioButtons, selectedOption } = useRadioButtons({
options: [`A`, `B`, `C`],
});
...
<RadioButtons {...props}/>
Instead of declaring all of the state inside every parent where this simple RadioButton component is needed, it's possible to just call useRadioButtons once to set it all up. Reading the parent makes it very clear exactly what belongs to the RadioButtons component and exactly what is returned by it, selectedOption. We could contain all of the state in the parent, but sometimes we just want whatever value a component is meant to give us.
Passing functions into the hook
This pattern also gives ample opportunity to create whatever parent-child relationships you need.
For one, you may have noticed that I pass in an optional onChange function. This is most useful if you need to trigger some specific function on change, but we can have fun with it and just throw an alert (don't do this).
const { props, RadioButtons, selectedOption } = useRadioButtons({
options: [`A`, `B`, `C`],
onChange: (selectedOption) => alert(selectedOption),
});
Getting functions out of the hook
What about calling a function from the parent to change something within the hook? You could use useImperativeHandle (which I have a post on), or you can set up the function and return it.
const useRadioButtons = ({ options = [], onChange = () => {}} = {}) => {
....
const reset = () => {
setSelectedOption(options[0]);
};
return {
props: {
options,
onChangeOption,
selectedOption,
},
RadioButtons,
selectedOption,
reset,
};
};
function Foo() {
const { props, RadioButtons, selectedOption, reset } = useRadioButtons({
options: [`A`, `B`, `C`],
onChange: (selectedOption) => alert(selectedOption),
});
return (
<>
<RadioButtons {...props} />
<button onClick={() => reset()}>Reset</button>
{selectedOption}
</>
);
}
This provides a function to reset the options to the first option (bound to a button in this example). As a bonus, this function will always refer to the correct instance of the component. No need to worry about passing around refs.
Don't do this one thing!
Don't return an instance of the component.
import React, { useState } from "react";
const useRadioButtons = ({ options = {}, onChange = () => {}} = {}) => {
const [selectedOption, setSelectedOption] = useState(options[0]);
const onChangeOption = (event) => {
setSelectedOption(event.target.value);
onChange(event.target.value);
};
return {
RadioButtons: () => (
<RadioButtons
{...{
options,
onChangeOption,
selectedOption,
}}
/>
),
selectedOption,
};
};
function Foo() {
const { RadioButtons, selectedOption } = useRadioButtons({
options: [`A`, `B`, `C`],
});
return (
<>
<RadioButtons />
{selectedOption}
</>
);
}
This will work and it looks nice, but you will re-create RadioButtons every render. Not only might this cause performance issues, but it will cause you quite a lot of headache if you put more complex state and hooks into useRadioButtons.
You can wrap it in a useCallback and mostly get a stable instance, but do it at your own risk. If you return the props and spread them into the component, you'll only re-render when you expect to. It's worth it for the peace of mind and doesn't add any lines of code to do so.
RadioButtons: useCallback(() => (
<RadioButtons
{...{
options,
onChangeOption,
selectedOption,
}}
/>
)),
Conclusion
Next time you want a smart, re-usable component, consider employing this pattern and see if you can even improve it. It's not a one-size-fits-all solution, but there are use cases where it might make your life just a bit easier. I hope this brief introduction has been useful!
In future posts, I'll discuss setting up many instances with different options with a hook, I'll get into the weeds of some issues you might run into, and I'll compare this to other methods of making components do what you want them to do.
Posted on April 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024