Brought to you by engine.so - a tool to instantly create a public self-service knowledge base for your customers with Notion.
Introduction
The "Container" pattern is a concept introduced in the Unstated-Next library. The pattern thinks about state as a variety of "Containers" that hold a modular slice of the global application state. To provide this state you create a Context across your application, then you can access it through hooks.
Compared to something like Redux, this Container pattern offers a hook-centric way to manage state. It's easier to learn, scales well with your application, and provides an intuitive way to think about global state. Here's how it works.
What is the Container Pattern?
The container pattern is a methodology where instead of having all your global state in one external library or "global store" such as Redux, you divide that state into multiple chunks called "Containers". These chunks are responsible for managing their own state and can be pulled into any functional component in the app by using something similar to the following syntax:
const {user} = Auth.useContainer();
This pattern works really well because it divides state up into self-managing chunks rather than having everything intertwined. Each component can simple pull in the chunk of state that it wants to use and is only dependent on a part of your applications state.
Each chunk of state is really easy to reason about. It's simply a custom hook wired up to a context provider. That's it. The term "Containers" is really just a wrapper term to mean "a React Custom Hook + a Context Provider", so when someone is recommending state management with hooks + useContext, they're technically recommending this container pattern.
To use containers you just have to import the Context and use the hook. You don't technically need any external libraries, however I use a library called Unstated-Next because it gives me some benefits that make this pattern even easier.
What is Unstated-Next?
Unstated-Next is a tiny library that helps us reason about these global containers a little bit easier. This library is tiny (like 200 bytes tiny), and it's for good reason because it basically doesn't do anything on top of what React's Context API already does.
This library is 100% optional for this design pattern. It just provides small API improvements that make Context easier to work with. Some of the main benefits include:
Type-Checking: It gives you typescript support out of the box. This was one of my gripes with using the React Context API, so it's nice to see that unstated-next solves that issue.
Error Handling: If you try to access a container that doesn't have a Context provider above it in the React DOM tree, it will throw an error. This is a life-saver for debugging.
Easier to Think About: Thinking about contexts can seem abstract at times, but using this library with the mental concept of "containers" is a lot easier to reason about.
What does this pattern look like?
File Structure
When I use this pattern, I put all my containers in a "container" folder at the root of the src directory.
I suffix each container with the word "Container" and have all the relevant code for a container all colocated in one file.
This already has benefits over something like Redux, where a single responsibility might be divided over 3 or 4 files for the actions, reducer, store, selectors etc.
The Container File
The container is where your slice of state will live. This file contains everything necessary for reading and writing to this part of state. Here's what a container file may look like for an AuthContainer:
// The reducer. This would be very similar to your reducer in Redux.
// This is optional, you can just use useState instead, but this is
// here to show that if you want to use a reducer and do more
// complicated state transitions you can.
function authReducer(state: AuthState, action: Action) {
...
}
// Custom Hook
function useAuth(initialState: AuthState) {
const [state, dispatch] = useReducer(authReducer, initialState);
const loginWithGoogle = () => {
dispatch(loggingIn());
doGoogleLogin()
.then(user => dispatch(success(user)))
.catch(err => dispatch(error(err.message)));
}
const loginWithEmailPassword = (email, password) => {
dispatch(loggingIn());
doEmailPasswordLogin(email, password)
.then(user => dispatch(success(user)))
.catch(err => dispatch(error(err.message)));
}
const logout = () => dispatch(logout());
return {
user: state.data,
isAuthenticating: state.loading,
error: state.error,
loginWithGoogle,
loginWithEmailPassword,
logout
};
}
// Create the Container (this can be a Context too)
// You just pass in the custom hook that you want to build the
// container for.
export const Auth = createContainer(useAuth);
This is really clean because it's basically just a custom hook and then that little line at the bottom to make it a container. When you add that container code at the bottom, it makes this custom hook have the same state even if used in multiple different components. This is because the Unstated-Next containers just use the Context API under the hood.
To make that work you first need to add a Store to your application which will store all the containers. This might look something like this:
Side-Note: I think there could be a better way to manage a Store like this. If there were a way to dynamically create this structure based on an array of containers or something like that, I think that would be a lot cleaner.
Also if there was a way to make all these load at the same level of the DOM so any container can access any other container that would be amazing too, but sadly I think that's a limitation with React.
You'll want to slot this in the root component so your root component looks something like this:
const App: React.FC = () => {
return (
<Store>
<ReactRouter>
<AppRoutes>
</ReactRouter>
</Store>
);
}
And voila! If you did this correctly, you should now be able to go into any of your React components and use this hook like the following:
const LoginPage: React.FC = () => {
...
const {
formLogin,
googleLogin,
isAuthenticating,
user
} = Auth.useContainer();
useEffect(() => {
if (user) {
history.push('/home');
}
}, [user]);
...
return (
<div>
<button onClick={() => googleLogin()}>
Login with Google
</button>
...
</div>
);
}
If you did everything correct, following this pattern should work for you! If you did something wrong Unstated-Next might throw an error that says that the container's provider hasn't been created, but that's good because it's an explicit error message for a bug that can be really difficult to track down if you're using the basic React Context.
Why Not Use Redux?
Redux is great for state management at a large scale. It's the tried-and-tested way to manage state for large applications. However, for the vast majority of applications out there, Redux is the wrong place to start. It's very boilerplate heavy and likely isn't going to give you many benefits unless you already know your use-case demands it.
Therefore I'm offering this pattern as an alternative.
The main benefit you get from this pattern is that it makes more sense from a developer's perspective. Redux takes all your state and pulls it away from the view layer. I'd argue that a better way to manage state would be to colocate it with the view layer that uses it.
This is why React Hooks exist.
You can already see things moving towards this methodology with the movement of other pieces of state out of things like Redux and into hooks:
- Local state => useState / useReducer
- API state => React-Query / useSWR / Apollo
- Form state => React Hook Form / Formik
Therefore, it makes sense that the global state also be built to fit well into a hook ecosystem.
Since a majority of my state management is done by various hook libraries, it makes sense that my global state management also be hook-centric.
The container pattern implements this idea. It offers the majority of the functionality as Redux at a fraction of the time-cost and is designed with hook-centric development at the forefront.
For any small-medium sized-project, this pattern is a no-brainer for me. For a larger project, it depends on the use-case.
Here's some comparisons between the container pattern and Redux:
The Container pattern has the following benefits:
- Less boilerplate than something like Redux.
- Uses the native Context API under the hood.
- You can learn the API in 10 minutes if you know useState, useContext and Custom Hooks.
- Only uses 1 tiny library, and even that dependency is optional.
It also has the following cons:
- No support for middlewares.
- No tool akin to the Redux chrome debugger ☹️.
- Containers must be provided in a certain order if they have dependencies on each other.
With this in mind, hopefully you now have a better idea of what sort of alternatives exist if your use-case doesn't demand something as bulky as Redux.
If you want to employ this pattern but can't quite leave Redux, another alternative would be a using Redux Toolkit + Redux Ducks Pattern. The Redux Ducks approach works well if you're building a large application because it uses this container-focused methodology, but still keeps you in the ecosystem of Redux.
Conclusion
This is the Container pattern. If you're looking at using Redux in an app, I would take a serious look at the cost of doing so to determine if your application actually requires it. I think this pattern is a good place to start regardless, and because it's so small and modular you can migrate it into Redux in the future really easily.
Overall, this pattern has helped me clean up my codebase a lot and remove state management from my list of pain-points when developing applications.
Anyways, Let me know what you think and hopefully it will work well in your projects. Enjoy!
Check me out to view more things like this: https://spencerpauly.com
Top comments (2)
Nice article, and an interesting concept. It sounds to me like you still end up with almost as much boilerplate though. You still need to write reducers (with useReducer), and you need to set up a store. At that point, you basically have a hooks-first Redux. React-connect provide hooks which allow you to access the store (using selectors, so here you do get more boilerplate), so it is still compatible with modern redux.
Personally I see problems with keeping the state alongside the view, especially when scaling, as your view layer would become inflexible and harder to change compared with the view layer as a state consumer.
Considering this library still requires some setup, maybe just plain useReducers or useContexts are enough for simple apps. If have greater needs than that I would still prefer redux I believe.
But I really enjoyed the idea presented. It is a very creative and cool use of context + hooks!
Nice article!
On the side note for the Store, you can take a look at recompose's nest.