React: Design Patterns | Container Components
In the world of React, there’s a design pattern called Container Components. If you’re a beginner or intermediate React developer, you might be used to having each child component load its own data. Typically, you’d use hooks like useState and useEffect along with libraries like Axios or Fetch to get data from a server. This works, but it can get messy when multiple child components need to share the same logic. That’s where container components come in.
Container components handle all the data loading and management for their child components. They abstract away the data-fetching logic, allowing child components to focus solely on rendering. This separation of concerns makes your code cleaner and more maintainable.
The Main Idea
The main idea behind container components is similar to layout components. Just as layout components ensure that child components don’t need to know or care about their layout, container components ensure that child components don’t need to know where their data is coming from or how to manage it. They just take some props and display whatever they need to display.
A Simple Example: CurrentUserLoader
Let’s start with a simple example. Suppose a CurrentUserLoader component loads the current user’s data and passes it to a UserInfo component.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
export const CurrentUserLoader = ({ children }) => {
const [user, setUser] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await axios.get('/current-user');
setUser(response.data);
};
fetchData();
}, []);
return (
<>
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { user });
}
return child;
})}
</>
);
};
In this example, CurrentUserLoader fetches the current user’s data and passes it down to its children as a user prop. The UserInfo component can then use this prop to display the user’s information.
Making It More Flexible: UserLoader
The CurrentUserLoader is useful but limited. It only loads the current user’s data. What if we want to load any user’s data by their ID? We can create a more flexible UserLoader component.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
export const UserLoader = ({ userId, children }) => {
const [user, setUser] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await axios.get(`/users/${userId}`);
setUser(response.data);
};
fetchData();
}, [userId]);
return (
<>
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { user });
}
return child;
})}
</>
);
};
Now, UserLoader can load any user’s data by their ID and pass it down to its children.
Going Generic: ResourceLoader
We can take this concept even further by creating a generic ResourceLoader component that can load any type of resource from the server.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
export const ResourceLoader = ({ resourceUrl, resourceName, children }) => {
const [state, setState] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await axios.get(resourceUrl);
setState(response.data);
};
fetchData();
}, [resourceUrl]);
return (
<>
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { [resourceName]: state });
}
return child;
})}
</>
);
};
With ResourceLoader, you can load any resource by specifying its URL and name. This makes the component highly reusable.
The Ultimate Flexibility: DataSource
Finally, let’s create a DataSource component that doesn’t even know where its data is coming from. Instead of hardcoding the data-fetching logic, we’ll pass a function that returns the data.
import React, { useState, useEffect } from 'react';
export const DataSource = ({ getDataFunction, resourceName, children }) => {
const [state, setState] = useState(null);
useEffect(() => {
const fetchData = async () => {
const data = await getDataFunction();
setState(data);
};
fetchData();
}, [getDataFunction]);
return (
<>
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { [resourceName]: state });
}
return child;
})}
</>
);
};
With DataSource, you can load data from any source — whether it’s an API, local storage, or something else — by passing a function that returns the data.
Conclusion
Container components are a powerful pattern in React that help you manage data loading and sharing logic across multiple components. By abstracting away the data-fetching logic into container components like CurrentUserLoader, UserLoader, ResourceLoader, and DataSource, you can make your code cleaner and more maintainable. This separation of concerns allows your child components to focus solely on rendering, making your application easier to understand and extend.
React: Design Patterns is a course by Shaun Wassell that you can follow on LinkedIn Learning.