6 Ways to Simplify State Management in React (Without Using Global State)
Jul 29, 2022 ☕ 11 min readDon't use global state for everything. Always use the right tool for the job.
React is an amazing framework but far from perfect, and one of the areas where it falls short is state management.
When React picked up steam in 2015, the promise of a performant Virtual DOM, react-ful state, one-way data binding, and iterative adoption in legacy projects was incredible.
However, once I started using React for large applications, I quickly ran into challenges like managing fetched data, sharing state across the application, persisting & hydrating data between sessions, and user session management.
If you wanted to use React for any large-scale application back in 2015, you had to use Redux.
If misused, it is easy to introduce a million boilerplate files that turn your codebase into spaghetti and slow down development. Nevertheless, Redux is a fantastic, battle-tested, state management tool. But it's just that: a tool. And today we have a lot more tools available to us - all great for different use cases.
I want to show you ways to simplify your React state using techniques you already have available. So you don't need to jump to one of these tools right away.
This is the first in a two-part series where I want to show you simpler ways to manage state in React applications. For using non-lifecycle tools like native browser storage check out Part Two.
Not it!
Just because you write React doesn't mean every variable should live in React state! Unfortunately, I see many newcomers making this mistake.
Stateful data comes with performance costs, maintainability, and the headache of dealing with the render lifecycle. The component lifecycle is the bread and butter of React, and the state is at its heart. But if you don't need to "react" to the data, keep it out of state!
Types of State
There are six mechanisms for storing and accessing data in a React application that we are going to focus on:
- Local State: Only one component needs the state
- Hooks: Re-usable state patterns
- Lifted State: A few related components need the state
- Context: Shared lifted state state
- Global State: Third-party libraries
- Fetched Data
And a Bonus at the end!
1. Local State
Local state is the simplest, and most maintainable (to a point) state management. If your component only needs to manage state internally, keep it there! Keep your state as close to its usage as possible.
With newer versions of React, local state is the simplest it's ever been with the usage of useState
and useEffect
hooks.
Note: It is possible to have more advanced use cases using hooks like useReducer
but be wary as such use cases can quickly start looking like re-writes of Redux.
Tips:
- Keep your state as flat as possible. Nested objects are unruly to manage and can introduce re-rendering bugs.
- Not everything needs to be a state object.
- Use succinct and descriptive names.
const Component = ({ onSelect }) => {const [data, setData] = useState([]);const [error, setError] = useState(null);const [selected, setSelected] = useState(null)useEffect(() => {// Fetch data to render on initial render of the componentfetchData().then(res => setData(res.data)).catch(e => setError(e));},[])useEffect(() => {// Do something when a user selects a rowonSelect(selected)},[selected])return (<>{error && <Error message="Oops something went wrong! Failed to fetch data" />}<Table><Header columns={["Name", "Description","Select"]}>{data.map(item => (<Row key={item.id} selected={selected === item.id}><Cell>{item.name}</Cell><Cell>{item.description}</Cell><Cell><Button onClick={() => setSelected(item.id)} label="Select" /></Cell></Row>))}</Table></>)}
However, we can't build React applications with only local state. That would make for a very poor user experience. Our components need to communicate and share data. This is why state management is such a key piece of your React architecture.
2. What about Hooks?!
Custom React hooks on their own are not a form of state management but are excellent utilities to access shared state like Context, URL, and Browser storage. You can also use them to abstract local or lifted state into re-usable state patterns used throughout your components without sharing data between them.
Tips:
- Keep your hooks focused on specific use cases. Don't try to cram everything into one hook. Separate it into multiple hooks by use case.
- If it is meant to be reusable: document the hook and place it in a directory that is accessible by other developers. I like to use an alias for
@hooks
and keep all globally reusable hooks in one place.
Example of a re-usable form state hook:
const useFormState = ({ initForm, validateFn }) => {const [formData, setFormData] = useState(initForm);const [dirty, setDirty] = useState(false);const [valid, setValid] = useState(false);const [validated, setValidated] = useState(false);const validate = () => {if (typeof validateFn === "function") {const isValid = validateFn(formData);setValid(isValid);setValidated(true);}};const onChange = ({ key, data }) => {setDirty(true);setValidated(false);setValid(false);setFormData((prevData) => ({ ...prevData, [key]: data }));};return { dirty, valid, validated, validate, onChange };};
3. Lifted State
The power of React is being able to build composable applications from many components. To orchestrate these components and create smooth user experiences they often need to talk to each other or share data. To do this we can move state "up" in the React tree to a local state in a shared parent component.
You should still keep the state as close to the components that need it in the tree as you can. The easiest way to achieve this pattern is by storing local state in a parent component and passing props
to the children.
Tips:
- Keep props as flat as possible to avoid unnecessary re-renders.
- Keep lifted state as localized as possible to the components that need it.
const Checkout = () => {const [items, setItems] = useState([]);const addItem = (item) => {setItems((items) => [...items, item]);};const removeItem = (id) => {setItems((items) => items.filter((item) => item.id !== id));};return (<><ProductsList onSelect={addItem} /><ShoppingCart items={items} onRemove={removeItem} /></>);};
However, as many of you know this pattern falls short very quickly the moment your parent-child tree grows too big and you start passing props through components to get it to the lower nodes. This is called "prop drilling" and when it happens you need to consider breaking down your use case and introduce other state patterns like a React context.
4. React Context
When your state needs to be accessed by components throughout your React tree it is best to detach your state management from the tree itself to avoid prop drilling. An excellent way to do this is to use React's Context API. You can build your own "mini" global state to match your specific needs.
Tips:
- Don't try to cram all of your state into a single context. If you do this you are re-creating a global state management tool and there are many third-party tools that can solve this for you.
- Do keep your context specific to a use case. Focus on the need for the state data and solve only that. Try to keep the context as simple as possible.
- If you start noticing a mess of nested Providers wrapping each other consider using a Providers component pattern: https://dev.to/hyposlasher/no-more-react-context-hell-4pm3
- Create a hook for simplifying the usage of
useContext(MyContext)
down to justuseMyContext
.
// Contextconst CartContext = React.createContext();const useCart = useContext(CartContext);const CartProvider = ({ children }) => {const [items, setItems] = useState([]);const addItem = (item) => {setItems((items) => [...items, item]);};const removeItem = (id) => {setItems((items) => items.filter((item) => item.id !== id));};return (<CartContext.Provider value={{ items, addItem, removeItem }}>{children}</CartContext.Provider>);};// App<CartContext.Provider><App /></CartContext.Provider>;// Componentconst ShoppinCart = () => {const { items, removeItem } = useCart();return (<>{items.map((item) => (<Item {...item} key={item.id} remove={removeItem} />))}</>);};
5. Global State libraries
What's good about the multitude of new state management libraries that have popped up is unlike Redux; which took a top-down view on state management, forcing you to start with the top of the state tree and work your way down. Many of these newer libraries guide you to start bottom-up. Starting with the consuming components and their use cases first. Which lends itself better to modularity and composability of the consuming components.
This is also exactly what we're trying to do by keeping state as close to the consuming component as possible - "pushing" the state further down the tree.
When you do find yourself in a situation where React Context and other methods no longer suffice and start introducing debugging issues. Or you find yourself rewriting Redux with useReducer
take a look at these new popular rising libraries:
- Zustand: Small, fast, and scalable Global State tool following Flux principles.
- Recoil: Global React state in an atomic graph structure parallel to your React tree.
- Jotai: Atomic state management inspired by Recoil.
- Xstate: Framework agnostic tool for structuring state and UI interactions in state machines.
9. Where should fetched data live?
Data fetched from a DB over an API should follow the same state rules as all other types of data. But it does come with some new patterns we should consider.
If the fetched data is large, there could be opportunities for caching - but be wary! Caching might seem like an optimization but can introduce many challenging bugs to debug if you're not careful. You should cache the data as close to the source as possible. The first option should always be to cache the data outside the browser for a more scalable solution.
If you need to do it in the browser, keep it close to the mechanism you use to fetch data. Whether it's axios
, superagent
, fetch
, or your own wrappers around these. I love using React hooks to control API interactions so you can easily track the data, loading, and error states. If this sounds interesting, check out react-query
.
const { isLoading, error, data, isFetching } = useQuery("repoData", () =>axios.get("https://api.github.com/repos/tannerlinsley/react-query").then((res) => res.data));
Like with other data, only move it up in the "tree" to reusable hooks, context or global state IF it needs to be accessed by other components. Keep the data at the lowest shared point in the tree to avoid polluting the entire application.
Bonus! Derived State
Derived state is not a new form of state management but instead another tip to avoid state management altogether.
If you have an existing state and need to transform it into a different form or use a sub-set, you don't need to store the result in another state object. The source of truth is already in the state, so any lifecycle will trigger re-renders on your component. Use this to your advantage and just "derive" your new data from the existing state values - whether it's local state, props, or elsewhere.
Don't do this:
const AdminUsers = ({ users }) => {const [adminUsers, setAdminUsers] = useState([]);useEffect(() => {setAdminUsers(users.filter((user) => user.permissions.indexOf("admin") > -1));}, [users.length]);return (<div>{adminUsers.map((user) => (<span key={user.id}>{user.name}</span>))}</div>);};
Do this instead:
const AdminUsers = ({ users }) => {// Derived state:const adminUsers = users.filter((user) => user.permissions.indexOf("admin") > -1);return (<div>{adminUsers.map((user) => (<span key={user.id}>{user.name}</span>))}</div>);};
Final Thoughts
State management is a tricky problem to solve but crucial to the success of any React architecture. Always be critical of your state use cases and think carefully about which pattern to follow. Keep your state as close to its usage as you can, and avoid polluting your codebase by unnecessarily sharing state no one else uses.
And don't stop there! All software is constantly changing, so look for opportunities to refactor your state to best serve the application's needs and help keep your codebase scalable. Nothing is worse than trying to build a new feature and having no idea what state you should use or how to access it.
I hope this has helped you think more critically about how to best store your data in React applications based on the use case.
I'd love to hear from you. Let me know what you think in the comments. What type of challenges have you run into with state? What are your favorite state patterns?
If you enjoyed this article check out Part Two.