Recently, we decided to modernize a legacy web application. This application was written back in 2018, before hooks were introduced to React. We used Redux for state management. As the years went on, and more features were added to React, Redux started to feel like an unnecessary complexity for this application.
So, the first question to answer was this: should we update this application to use the latest version of Redux (preferably with the Redux Toolkit), or should we completely replace our Redux store—along with all the accompanying actions, reducers, and sagas—with simpler, lightweight state management in the form of hooks and libraries like React Query?
One of my biggest complaints about the way we used Redux in this application is that it requires writing a lot of boilerplate. If we were to stick with Redux, we would want to switch to Redux Toolkit, which allows you to accomplish the same functionality with a fraction of the boilerplate. However, this would involve rewriting all our Redux code. I did a proof of concept with a small portion of our Redux store, and it was clear that updating our Redux codebase would be akin to rewriting all our state management. So, keeping Redux in this application would not take less effort than just replacing Redux entirely for state management.
We explored the path of switching from Redux to hooks for our state management, and it turned out to be surprisingly straightforward. In this blog post, we'll explore why that is, and what implications this has for structuring a React code base. In the process, we'll discuss whether an old concept—smart components vs. dumb components—still has a place in modern React development.
Sample of Our Redux
To demonstrate what "flavor" of Redux our application used, let's look at a component named "NewEntryForm":
interface FormProps {
isVisible: boolean;
selectedEmployeeName: string;
selectedDate: Date | null;
employees: Employee[];
selectEmployee: (name: string) => void;
selectDate: (date: Date | null) => void;
closeForm: () => void;
save: () => void;
}
export function NewEntryForm({
closeForm,
isVisible,
save,
selectedDate,
selectedEmployeeName,
selectDate,
selectEmployee,
employees
}: FormProps) {
return (
selectEmployee(item.key as string)}
options={employees.map(e => ({ key: e.name, text: e.name } as IDropdownOption))}
/>
selectDate(date || null)}
/>
);
}
export default connect(
(state: FullStore) => ({
isVisible: newEntryFormIsVisibleSelector(state),
selectedEmployeeName: newEntryEmployeeNameSelector(state),
selectedDate: newEntryDateSelector(state),
employees: employeeOptionsForNewEntryFormSelector(state)
}),
(dispatch: Dispatch) =>
bindActionCreators(
{
selectEmployee: selectEmployeeForNewEntry,
selectDate: selectDateForNewEntry,
closeForm: hideNewEntryForm,
save: saveNewTimeEntry
},
dispatch
)
)(NewEntryForm);
Although this code is now seven years old and pre-dates hooks, it should look familiar to anyone who's used React with TypeScript. We're declaring the type of props, and then we're declaring a pure functional component. I call this component pure because it will always render the same output for a given set of props.
The part that's unique to old-school Redux is the bit involving connect()
at the bottom. This is a higher order component provided by the react-redux
library, and it does exactly what it sounds like—it "connects" a React component to the Redux store. It gets various action handlers and pieces of state from the Redux store and provides those things as props to our React component. Here's where I get to invoke a term that was popular in the early days of React.
In this example, the function "NewEntryForm" is what is known as a presentational component. It does nothing more than take props and render JSX based on those props. It doesn't manage any of its own state. The component created by the connect
function, on the other hand, is what's known as a container component. Container and presentational components are also known as smart and dumb components, or stateful and pure components.
When I want to use this component, I can choose to use the smart component by doing a default import:
import NewEntryForm from './newEntryForm'
Or I can choose to use the "dumb" component by doing a regular import:
import {NewEntryForm} from './newEntryForm'
One of the advantages of this approach is that it makes the component more reusable. If I want to reuse this same component in two places, but it needs to get its state from somewhere different in both places, I can simply have two different connect()
HOCs that get the props from different parts of the Redux store and use them both to render the NewEntryForm component. In other words, two smart components rendering the same dumb component.
Replacing Redux with Hooks
With few exceptions, every component in the application followed this pattern. We were storing most of our application's state in the Redux store, and so all our smart components were just using connect()
to grab state and event handlers from the Redux store and passing them to our dumb components as props.
This is an extremist approach to Redux development, but it did come with a benefit: we can replace the state management for the application with minimal changes to our component files themselves.
Take another look at that NewEntryForm component file. Everything up until the connect()
part can stay the same, and I can just replace the connect()
HOC with the following:
interface NewEntryFormContainerProps {
closeForm: () => void;
save: (employeeName: string, date: Date) => void;
}
export default function NewEntryFormContainer({ closeForm, save }: NewEntryFormContainerProps) {
const employeesQuery = useAllEmployeesQuery();
const employees = useMemo(
() =>
employeesQuery.data
? linq
.from(employeesQuery.data)
.where((e) => e.isActive)
.orderBy((e) => e.name)
.toArray()
: [],
[employeesQuery.data]
);
const [selectedEmployeeName, setSelectedEmployeeName] = useState();
const [selectedDate, selectDate] = useState(null);
const saveCallback = useCallback(() => {
if (!selectedEmployeeName || !selectedDate) {
return;
}
save(selectedEmployeeName, selectedDate);
}, [selectedEmployeeName, selectedDate, save]);
return (
);
}
This new component still has two props (closeForm and save) because in the new version of the app we decided to keep that state at a higher level of our component tree, but we're getting everything else from some local hooks instead of from Redux. We're now getting "selectedEmployeeName" and "setSelectedEmployeeName" from useState()
instead of from a Redux store. We're getting our array of employees from useQuery()
, and we're doing some filtering and sorting within a useMemo()
to get the final value that we pass to the "employees" prop of the presentational component.
To summarize, this component has a very specific job: manage state and provide that state as props to a presentational component.
If we want, we can also choose to put this "smart" or "container" component in a separate file. We could name the file with the presentational component "newEntryForm.presentation.tsx" and we could name this new file "newEntryForm.container.tsx".
This container vs. presentation paradigm isn't specific to migrating from Redux. Migrating from Redux to any other state management solution (Zustand, MobX, perhaps even React Server Components in a framework like Next.js) would be simpler than it would have been if our state and presentation was intermingled.
Does This Mean Container Components are Making a Comeback?
Benefits of Separation of Concerns
With that in mind, some benefits of having separate container components are:
- Code reuse. Presentational components are more flexible and easier to reuse, because they maintain no state of their own. In the example above, I'm getting an array of all active employees for the "employees" value. If in a different part of the app I want to reuse this same form but only allow the user to select from their direct reports instead of all employees, I can reuse the "NewEntryForm" component as is by wrapping it in a container that gets the "employees" array from a query of the user's direct reports. Same presentational component, different container.
- Easier refactoring of state. In the example above, if I want to switch from using hooks for state management and instead use Zustand for state management, I don't need to change anything about my "NewEntryForm" component. I only have to modify or rewrite the container. Even for a more drastic change, like migrating the application to Next.js with app router and React Server Components (RSCs), I can migrate my "NewEntryForm" component unchanged and only have to replace my container component with a server component. That server component would probably involve using a database request to get the array of employees. We might also replace the "save" function with a server action. Finally, we would likely still have a client component for the container, which is where we would set up our "useState" and other hooks. In the end, the NewEntryForm component doesn't need to be changed at all.
The separation of concerns principle has other benefits, but some of those benefits may not apply to the presentational vs. container components paradigm. One of the benefits of separation of concerns is maintainability—you can make changes to one layer without changing the other. In the example above, we could switch from e.g. a Dropdown for the employee select to a ComboBox, and we only need to modify the NewEntryForm (presentational) component to do that. We don't need to touch the container component.
In a real application, this may not help us as much as we'd like. If we need to add a new feature to this form, e.g. a new "Comments" field, we'll need to modify both the presentation component and the container—add the new field to the presentation component's JSX and add the new piece of state to the container. There are a lot of feature requests or bug fixes that will require modifying both components, which is why many of the benefits of separation of concerns won't necessarily apply to container vs. presentational components.
Functional Components Already Have Separation of Concerns Built in
One reason why container components aren't talked about as much as they used to be is because of the nature of functional components and the ease with which they can be refactored. Let's write a very simple React component to demonstrate this:
function CustomersList() {
const customersQuery = useQuery({
queryKey: ['customers'],
queryFn: async () => {
const response = await fetch('http://someUrl')
return response.json()
}
})
const customers = useMemo(() => customersQuery.data || [], [customersQuery])
return (
{customers.map(c => - {c.name}
)}
)
}
The above component uses react-query
's "useQuery" hook to fetch data from some URL, and then it renders an unordered list based on the data that comes back.
At a glance, we can tell this is not a presentational component, because it manages its own state. It's also not a container component, because it renders JSX instead of just passing that state as props to other custom components. So, is it just a big nothing?
Another way to look at it is that we have two distinct sections of code here: we have a "state" section where we're doing our useQuery()
and useMemo()
stuff, and we have a "presentation" section where we're rendering our UI with JSX. That structure makes it very easy to refactor and restructure this code. If a couple months later in development we want to reuse the presentation part of this component, we can just highlight the return
statement of this component and cut/paste it into a new component. There are even some Visual Studio Code extensions that allow you to refactor a React component like this with the click of a button.
// here we have our refactored presentation component
function CustomersList (props: {customers: Customer[]}) {
const customers = props.customers
return (
{customers.map(c => - {c.name}
)}
)
}
// and then we have our original state in a container
function CustomersFromEndpoint () {
const customersQuery = useQuery({
queryKey: ['customers'],
queryFn: async () => {
const response = await fetch('http://someUrl')
return response.json()
}
})
const customers = useMemo(
() => customersQuery.data || [],
[customersQuery]
)
return
}
// and now we can re-use our presentation component with another
// container with different state, this time getting the customers list
// from some hook that interacts with IndexedDB
function CustomersFromIndexedDB () {
const customers = useIndexedDBCustomersTable()
return
}
It could be argued that we no longer need to think ahead and separate our components into presentational and container components because we can separate them whenever we want on a moment's notice.
It's a Spectrum, not a Binary Choice
Like so many things in life, the statefulness of a React component is not a binary affair; it is a spectrum. Let's revisit our NewEntryForm example, but in a new way:
export default function NewEntryFormContainer({ closeForm, save }: NewEntryFormContainerProps) {
const employeesQuery = useAllEmployeesQuery();
const employees = useMemo(
() =>
employeesQuery.data
? linq
.from(employeesQuery.data)
.where((e) => e.isActive)
.orderBy((e) => e.name)
.toArray()
: [],
[employeesQuery.data]
);
const [selectedEmployeeName, setSelectedEmployeeName] = useState();
const [selectedDate, selectDate] = useState(null);
const saveCallback = useCallback(() => {
if (!selectedEmployeeName || !selectedDate) {
return;
}
save(selectedEmployeeName, selectedDate);
}, [selectedEmployeeName, selectedDate, save]);
return (
setSelectedEmployeeName(item.key as string)}
options={employees.map((e) => ({ key: e.name, text: e.name } as IDropdownOption))}
/>
selectDate(date || null)}
/>
);
}
This version of the NewEntryForm component is neither purely a container, nor is it purely presentational. It maintains its own state (quite a bit of it, at that), and it returns a very well-defined UI (dropdown, a datepicker, and a couple buttons). But you should note that this component doesn't not manage all of its own state. It's still receiving the "closeForm" and "save" functions as props rather than defining those functions itself.
If we wanted to push this component further toward the "container" end of the spectrum, we might decide to define that "save" function right here within this component. Instead, let's push this component a bit more toward the presentation side of the spectrum to enhance reusability:
export default function NewEntryFormContainer({ employees, closeForm, save }: NewEntryFormContainerProps) {
const [selectedEmployeeName, setSelectedEmployeeName] = useState();
const [selectedDate, selectDate] = useState(null);
const saveCallback = useCallback(() => {
if (!selectedEmployeeName || !selectedDate) {
return;
}
save(selectedEmployeeName, selectedDate);
}, [selectedEmployeeName, selectedDate, save]);
return (
setSelectedEmployeeName(item.key as string)}
options={employees.map((e) => ({ key: e.name, text: e.name } as IDropdownOption))}
/>
selectDate(date || null)}
/>
);
}
We are now receiving the "employees" array as props to this component, instead of managing that state directly in this component via useQuery()
. That enhances our reusability, because now we can display this component in multiple places in our application using a different data source for the employee options each time.
But we still have a bunch of state defined in this component. This doesn't hurt reusability, because these are the kinds of state that should be tightly coupled to the presentation. After all, what good is DatePicker without a corresponding piece of state to keep track of its value? Ditto for the dropdown. An exception there would be if you're using server actions along with uncontrolled inputs, but if you're doing that, you're likely doing the same in each place where you're using this component.
The other reason this helps with reusability is that now the interface of this component (AKA the props) is smaller, so we don't have to define as much state in the parent component to use this component. That results in less repeated code.
Come on, Dude, Just Tell Me What to Do
There is no "always do this" or "never do that" kind of rule to take away from this article. Here are my takeaways:
- Having a strict separation of presentation components and container components will result in writing more boilerplate but has the benefit of giving you a crystal-clear scope of what needs to be rewritten if you ever decide to change your state management strategy, e.g. from Redux to hooks or hooks to Zustand.
- The nature of functional components and hooks lends itself to easy refactoring. If you generally follow sound component design principles, any decisions you make regarding the statefulness of a component are not permanent and you can always change your mind.
- Statefulness is a spectrum. It is not a dichotomy of containers and presentational components. A component can maintain some of its own state without hurting its reusability, and doing so can even increase its reusability. It is perfectly fine to get some things from props while also maintaining some state via hooks.
Learn more about our Application Development expertise and contact us for your next project.