Generic Data Containers: Three Levels of Abstraction

A container hardcoded to one endpoint is a start. Remove assumptions one at a time and you end up with a DataSource that fetches from any source: API, localStorage, Redux, or a mock.

June 27, 20264 min read3 / 3

The previous post built a CurrentUserLoader that fetches one specific endpoint and injects the result into its children. It works. But it only works for that one endpoint.

Every time a new data source appears, you write another container. BookLoader. OrderLoader. NotificationLoader. They all look identical. They all have useState(null) and useEffect with an axios.get. That repetition is the signal to abstract.

The question is: which assumption do you remove first?

Level 1: Remove the Hardcoded Endpoint

The first container only fetches /current-user. That is too specific. Accept a userId prop and let the caller decide which user to load.

JSX
const UserLoader = ({ userId, children }) => { const [user, setUser] = useState(null); useEffect(() => { (async () => { const response = await axios.get(`/users/${userId}`); setUser(response.data); })(); }, [userId]); return ( <> {React.Children.map(children, child => React.isValidElement(child) ? React.cloneElement(child, { user }) : child )} </> ); };

userId in the dependency array means the effect re-runs whenever the ID changes. Two instances with different IDs fetch independently.

Level 2: Remove the Hardcoded Resource Type

A UserLoader only loads users. A ResourceLoader loads anything. Accept the URL and the prop name separately.

JSX
const ResourceLoader = ({ resourceUrl, resourceName, children }) => { const [resource, setResource] = useState(null); useEffect(() => { (async () => { const response = await axios.get(resourceUrl); setResource(response.data); })(); }, [resourceUrl]); return ( <> {React.Children.map(children, child => React.isValidElement(child) ? React.cloneElement(child, { [resourceName]: resource }) : child )} </> ); };

The computed key [resourceName] means the caller controls which prop name the child receives.

JSX
<ResourceLoader resourceUrl="/users/2" resourceName="user"> <UserProfile /> </ResourceLoader> <ResourceLoader resourceUrl="/books/1" resourceName="book"> <BookCard /> </ResourceLoader>

Same component, two completely different data shapes. This is the same spread trick from the list post.

Level 3: Remove the HTTP Dependency Entirely

The ResourceLoader is still coupled to axios. A DataSource has no opinion about where data comes from. It receives a function and calls it.

JSX
const DataSource = ({ getData = async () => {}, resourceName, children }) => { const [resource, setResource] = useState(null); useEffect(() => { (async () => { const data = await getData(); setResource(data); })(); }, [getData]); return ( <> {React.Children.map(children, child => React.isValidElement(child) ? React.cloneElement(child, { [resourceName]: resource }) : child )} </> ); };

The caller provides any async function:

JSX
const fetchUser = async () => { const res = await axios.get('/users/3'); return res.data; }; <DataSource getData={fetchUser} resourceName="user"> <UserProfile /> </DataSource>

Now it also works with localStorage:

JSX
const getFromStorage = (key) => () => localStorage.getItem(key); <DataSource getData={getFromStorage('session-token')} resourceName="token"> <AuthBanner /> </DataSource>

DataSource does not care if the data comes from a REST API, a Redux store, a mock, or localStorage. The caller decides. The container just calls the function and passes the result down.

The Render Prop Alternative

React.cloneElement injects props without the call site knowing it happens. If you want that flow to be explicit, use a render prop instead.

JSX
const DataSourceWithRender = ({ getData = async () => {}, render }) => { const [resource, setResource] = useState(null); useEffect(() => { (async () => setResource(await getData()))(); }, [getData]); return render(resource); };

Usage:

JSX
<DataSourceWithRender getData={fetchUser} render={user => <UserProfile user={user} />} />

The prop name is now visible at the call site. The trade-off: more verbose, but nothing is hidden. Both approaches are valid. The render prop version is easier to trace in a large codebase. The cloneElement version is cleaner when the child component list changes frequently.

The Essentials

  1. Remove one hardcoded assumption at a time: endpoint first, resource type second, HTTP library third.
  2. DataSource accepts a getData function. The caller decides the data source. The container just calls it.
  3. Computed keys ([resourceName]) let the container pass the right prop name without knowing what it is.
  4. Render props make data flow explicit at the call site. cloneElement keeps the call site clean but hides injection.
  5. The same component can now load from an API, localStorage, or a test mock, with no changes to the container.

Further Reading and Watching

Practice what you just read.

Build the DataSource
1 exercise