Structuring React: Containers and UI Components
June 23, 2023 - 8 min read
intermediate
react
typescript
Containers and UI components is a tried and true method of structuring your React code
in a scalable, cleanly separated way. Here are some reasons to use Containers
and UI components in your codebase:
- Clean separation of non-UI data fetching code from pure UI code
- Promotes code that is easier to unit test
- Containers are transparent, and easy to opt into
- Provides common convention for structuring code
If you are not following the structure of Containers and UI components, you might
have a component that looks like this:
import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Flex, Text } from 'design-system'; import { ErrorPage } from 'components/Errors'; import { Loader } from 'components/Loader'; import { useFetchUsers } from 'hooks/useFetchUsers'; function UsersList() { const { t } = useTranslation(); const { users, isError, isLoading } = useFetchUsers(); const activeUsers = useMemo( () => users.filter((user) => user.active), [users], ); if (isError) { return <ErrorPage />; } if (isLoading) { return <Loader />; } return ( <Flex direction="column"> <Flex> <Flex>{t('firstName', 'First Name')}</Flex> <Flex>{t('lastName', 'Last Name')}</Flex> </Flex> {activeUsers.map((user) => ( <Flex key={user.id}> <Text>{user.firstName}</Text> <Text>{user.lastName}</Text> </Flex> ))} </Flex> ); }
Let's break down all the differen types of code that are in this component.
- Internationalization Translation Hook
- Data Fetching Hook
- Memo'd Computation
- Error State
- Loading State
- User List UI
For such a small component, theres a lot of stuff going on here! Let's think of some
scenarios where this code structure might be inconvenient.
What if I'd like to reuse the User List UI components in another area of my application.
Should I rewrite the UI separately, thereby duplicating the same code? Should I move
the UI components to a separate component so that they can be reused?
What if I need to calculate the active users elsewhere? Should I rewrite the
useMemo
computation? Should I abstract that computation to a separate hook? Should I include
that computation in the user fetching hook?What would I need to do to write a unit test for the UI? I need to mock
the translation hook and the user fetching hook. I also need to mock the ErrorPage
and the Loader components if they are complex.
Container Components
Continuing with the example above, let's rewrite this component as separate Container and UI components.
First, the UI should be pretty straightforward. We can grab every line of code that renders a UI. This
should end up being a pure function most of the time.
UsersList - UI Component
This is our new UI component, which is a pure function. It takes in some props, and renders a UI. It does
not contain any non-UI code.
import { Flex, Text } from 'design-system'; import { ErrorPage } from 'components/Errors'; import { Loader } from 'components/Loader'; interface Props { isError: boolean; isLoading: boolean; t: TranslationFunction; users: User[]; } function UsersList() { if (isError) { return <ErrorPage />; } if (isLoading) { return <Loader />; } return ( <Flex direction="column"> <Flex> <Flex>{t('firstName', 'First Name')}</Flex> <Flex>{t('lastName', 'Last Name')}</Flex> </Flex> {users.map((user) => ( <Flex key={user.id}> <Text>{user.firstName}</Text> <Text>{user.lastName}</Text> </Flex> ))} </Flex> ); }
UsersListContainer - Container Component
This component contains no UI code, other than eventually returning the UsersList
which is now a pure UI functional component.
import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useFetchUsers } from 'hooks/useFetchUsers'; import UsersList from './UsersList'; function UsersListContainer() { const { t } = useTranslation(); const { users, isError, isLoading } = useFetchUsers(); const activeUsers = useMemo( () => users.filter((user) => user.active), [users], ); return ( <UsersList isError={isError} isLoading={isLoading} t={t} users={activeUsers} /> ); }
More is less?
Now you might be looking at this code and thinking, wow, this is a bit more code
than what we originally started with. The original code was nice and tidy, all contained in one file.
Why should I do all this extra scaffolding and write more code?
The reality is that components are never that small and tidy. Have you ever opened a component that is
500+ lines long, half of which is complex hooks and calculations, and the other half is rendering
a UI? This is a very common pattern that I see all the time. You start with a simple component,
and then you need to add some more functionality. It grows and grows until you have a behemoth. Unit tests?
Good luck.
The Container and UI components approach is scalable. It sets up definitions for what a type of component should be.
A component either handles data fetching, form submission/validation, and computations, or it renders a UI. It never
does both!
Common Pitfalls
Containers that are not transparent
Continuing with our UsersList/UsersListContainer above, a common mistake that people make is exporting the Container
and using it everywhere, named as a Container:
🚫 Using components named Container
function App() { return ( <PageContainer> <NavigationContainer /> <UsersListContainer /> <FooterContainer /> </PageContainer> ); }
✅ Using Containers as regular components
function App() { return ( <Page> <Navigation /> <UsersList /> <Footer /> </Page> ); }
✅ Index files
Index files are great for this, because they allow you to export Containers without having to expose
the fact that you've got a container. This makes using Container opt-in, because you can start
by exporting the UI component, and if you need to add non-UI code, you can easily layer in a Container
without needing to rename your components everywhere.
Since containers only provide data internally to their paired UI component, the rest of your application
does not need to know if a component is also a container.
// exporting the ui component export { default as UsersList } from './UsersList'; // or if you need to add a container // exporting a container as UsersList export { default as UsersList } from './UsersListContainer';
Passing Prop Types
A common mistake when writing Container and UI components that need to share props is writing the same
prop types twice. Using typescript interfaces, it is possible to extend from a common interface that
represents props that are shared between the Container and the UI components.
// props used by the container component export interface ContainerProps extends SharedProps { // filter by active users active: boolean; } // props used by the ui component export interface Props extends SharedProps { t: TranslationFunction; users: User[]; } // props used by the container and ui components interface SharedProps { variant: 'list' | 'table'; }
import type { ContainerProps } from './UsersList.types'; function UsersListContainer({ active, ...props }: ContainerProps) { const { t } = useTranslation(); const { users } = useFetchUsers(); // use the "active" prop in the container const users = useMemo(() => { if (!active) { return users; } return users.filter((user) => user.active); }); return ( <UsersList // pass through "variant" to UI {...props} // pass translation function from hook in container t={t} // pass users, may be active or all users depending on active prop users={users} /> ); }
✅ Use spread operator to pass through shared props
Using the spread operator is very helpful when you have some shared props
that you ultimately need to use in the UI component. By referring to those props
as
...props
in your Container, you are effectively saying: Just pass through
all those props, we don't need them in the Container.Conclusion
Hopefully this was a helpful introduction to Containers and UI components. Everything in programming
is a trade-off, and here we are trading off needing to write slightly more code for a more
structured and defined codebase. For smaller projects this may feel like a wasted effort, but as a codebase
grows these types of structured approaches will be very important.
Defining a structure for a codebase, along with using linting/formatting tools, and Typescript is a great
approach to ending up with applications that are maintainable at scale. It is very easy to start a new project
and get running quickly, but maintaining a high quality codebase over many years is a tough effort.
If you enjoyed this post, you might also like these: