Photo by Xavi Cabrera on Unsplash
Create reusable React components
Let's have a look on the philosophy and techniques behind creating reusable React components, as advised by the React and Netflix teams themselves.
Creating reusable components in React is very straightforward, because the React team actually had code reuse in mind when building the library.
React is built around a composition model which favors, well, composability. We just need to nail down the basics and we'll be creating reusable components in no time.
Revisiting props and state
Let's get this out of the way.
Props is how you make components configurable and customizable.
State is how you enable interactivity.
It is very important to understand this because, when you're creating reusable components, you'll be using like 70% props and 30% state.
If you read the Thinking in React chapter of the documentation, you'll find this very interesting advice:
The easiest way is to build a version that takes your data model and renders the UI but has no interactivity [...] don't use state at all to build this static version. State is reserved only for interactivity.
See? It's official. I'm not making this up ๐
Two types of state
The way I see it, you can have data state and interaction state.
Data state usually comes from some kind of storage, like an API, Local Storage, Session Storage, file, URL parameters...
Interaction state is the one that tracks what the user is doing in your app, like clicking, typing, choosing and dragging things around.
Two types of components
Have you ever heard about Statefull Containers and Stateless Presenters?
Statefull Containers will be the components that know all about your data state, and none or very little about interaction state.
Stateless Presenters are the components we've been discussing so far, the reusable ones. They know nothing about data state, and everything about interaction state.
Containers are less reusable, because they will be coupled to external dependencies. They will know about your API. They will make the call, normalize the data, stitch it together with local storage... and then they'll pass it to the Presenters.
Presenters will receive data as props and straight up render them. They will not depend on fetch, or axios, react router... in the ideal world, they also won't depend on Context.
This is the ideal, which is not always possible. But it is what you should strive for. Let's see some examples.
Examples
1. TabNavigation
In a reusable TabNavigation component, tab names and tab content come from props. It may also accept aditional props to configure the starting tab, and a callback to let the parent component know when the active tab changed.
It uses state to enable interaction, which is to let the user change tabs.
// Presenter
export default function TabNavigation({ tabs, startingTabIndex, onTabChange }) {
const [current, setCurrent] = useState(startingTabIndex);
return <div>render tabs here...</div>;
}
// Container
export default function SomePage() {
const [tabs, setTabs] = useState({});
useEffect(() => {
axios.get("myapi.io/users/10").then(res => {
setTabs({ ...tabs, "Profile": res.data.description });
});
axios.get("myapi.io/details/10").then(res => {
setTabs({ ...tabs, "Details": res.data.description });
});
}, []);
return (
<div>
<h1>Welcome!</h1>
<TabNavigation tabs={tabs} />
</div>
);
}
2. AutoComplete
In a reusable AutoComplete component, the options that are available for completion come from props. Since the AutoComplete is essencially a text input that offers suggestions that the user can pick from, then it will also expect value
and onChange
to be provided as props.
It uses state to filter options that will be offered as suggestions, based on what the user is typing.
// Presenter
export default function AutoComplete({ options, value, onChange }) {
const [acceptedSuggestion, setAcceptedSuggestion] = useState("");
const suggestions =
value?.length && value !== acceptedSuggestion
? options.filter(opt => opt.includes(value))
: [];
return (
<div>
<input type="text" value={value} onChange={onChange} />
<ul>render suggestions here...</ul>
</div>
);
}
// Container
export default function SomePage() {
const [names, setNames] = useState([]);
const [name, setName] = useState("");
useEffect(() => {
axios.get("myapi.io/users").then(res => {
names = res.data.map(user => user.name);
setNames(names);
});
}, []);
return (
<div>
<h1>Welcome!</h1>
<AutoComplete
options={names}
value={name}
onChange={name => setName(name)} />
</div>
);
}
Analysis
In the examples above, we can see that:
โ The 70% props x 30% state idea holds true.
โ Configuration (options) and customization (data) come from props.
โ State is used to enable interactivity.
โ The Presenter components leave it up to the Statefull Container to worry about fetching data from somewhere and passing it down.
โ The Presenter components are highly unit-testable; if you don't need to mock anything, then you're doing it right ๐ Containers are a bit harder to test, but that's expected, since they were left with all the external dependencies.
3. AutoComplete FROM AN API ๐ฑ
NOOOOOOOOOOOOOOO!!! What if I want to create a reusable AutoComplete component that accepts an URL as prop, and then it imports axios and takes care of fetching the data itself?
Not a real problem, though there are better alternatives. It still adheres to all the principles discussed so far, even though it makes an API call.
It is very common for components like this to impose a format on the data returned by the API, so that the component is not coupled to one specific API, but rather the oposite, you must create an endpoint that is tailored to work with that component.
The remote source is supplied as a configuration parameter (i.e. a prop), and so the component is still very much reusable. No harm done.
The better alternative, though, is to accept a function prop that will act as the data provider.
export default function SomePage() {
async function loadOptions() {
const res = await axios.get("https://jsonplaceholder.typicode.com/users");
return res.data.map(user => user.name);
}
return (
<div>
<h1>Welcome!</h1>
<AutoComplete
source={loadOptions}
value={name}
onChange={name => setName(name)} />
</div>
);
}
Also, consider using the Specialization technique, maybe creating an AutoComplete
component that does not load options from an API, and a RemoteAutoComplete
which uses composition to build on the AutoComplete
component, adding API support.
4. Fetch
This is a nice one:
export default function Fetch ({ url, children, ...rest }) {
const [state, setState] = useState({
loading: true,
error: null,
data: null,
});
useEffect(() => {
axios.get(url)
.then(res => setState({ ...state, loading: false, data: res.data })
.catch(err => setState({ ...state, loading: false, error: err.message });
}, []);
return (
<div>
{children({
...state,
...rest,
})}
</div>
);
}
To be used like this:
<Fecth url="myapi.io/users">
{({ loading, error, data }) => {
if (loading) return <span>Loading...</span>;
if (error) return <span>Error: {error}</span>;
return (
<div>
{data.map((user) => (
<div>{user.name}</div>
))}
</div>
);
}}
</Fecth>
Further reading
To make components even more reusable, these techniques come in very handy!
- Render props: a way to share state and behavior between components without tightly coupling them, a render prop is function that allows you to dynamically render things, receiving a component's internal state as an argument. Learn more.
- Custom hooks: encapsulate state and behavior into reusable functions. You can write custom Hooks that cover a wide range of use cases like form handling, animation, declarative subscriptions, timers, and many more. Learn more.
- Lift state up: when components need to reflect the same changing data, lift it up to the closest common ancestor. Learn more.
I also recommend watching this excelent talk by Mars Jullian, Senior UI Engineer at Netflix, sharing best practices for writing reusable components in React. What she presents there is very much in sync with what we discussed here. Or should it be the other way around? ๐ค๐
Have a blast!