Building a complex JavaScript application is a lot like trying to keep a very busy, very messy room organized. You have data coming in from users, from servers, from other parts of your app. This data—the current user’s name, the items in their cart, whether a sidebar is open—is what we call “state.” If you just throw this data anywhere, your app becomes chaotic and impossible to fix when something goes wrong. State management is simply the set of rules you decide on to keep that room tidy.
Over the years, developers have come up with different “rules” or patterns for this. Some are for small rooms (simple apps). Some are for managing entire warehouses (large-scale apps). There is no single best way. The right pattern depends on how big your project is, what your team knows, and what you’re trying to build.
I want to walk you through seven of these patterns. We’ll start with the simplest, the one you probably already use, and work our way up to more powerful tools. Think of it as choosing the right container: from a small drawer to a full warehouse management system.
The simplest pattern is keeping state close to home. When a piece of data is only needed by one component—like the text inside an input field or whether a button is hovered—you keep it right there. In React, you use useState. In Vue, you use data() or ref. This is your first tool, and it’s often the only one you need for many parts of an app.
// A simple counter component. Its state lives and dies with the component.
function LikeButton() {
// 'count' and 'setCount' are managed entirely within this button.
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // Update the local state.
};
return (
<button onClick={handleClick}>
Likes: {count}
</button>
);
}
The beauty of local state is its isolation. This LikeButton can be used 100 times on a page. Each will have its own independent count. The problem begins when two separate components need to share and stay in sync with the same piece of information.
That brings us to the next step: lifting state up. Imagine you have a TemperatureInput for Celsius and a TemperatureDisplay for Fahrenheit. They both need to reflect the same temperature. If each manages its own state, they can get out of sync. The solution is to “lift” the shared state to their closest common parent.
The parent becomes the single source of truth. It holds the state and passes it down to the children. The children communicate up by calling functions passed from the parent.
function TemperatureController() {
// State is lifted HERE, to the common parent.
const [celsius, setCelsius] = useState(25);
// The parent calculates the derived Fahrenheit value.
const fahrenheit = (celsius * 9/5) + 32;
return (
<div>
{/* Child receives state and a way to update it */}
<CelsiusInput value={celsius} onInputChange={setCelsius} />
{/* Sibling receives the calculated value */}
<FahrenheitDisplay value={fahrenheit} />
</div>
);
}
function CelsiusInput({ value, onInputChange }) {
return (
<input
type="number"
value={value}
onChange={(e) => onInputChange(Number(e.target.value))}
/>
);
}
function FahrenheitDisplay({ value }) {
return <p>{value.toFixed(1)} °F</p>;
}
This pattern is fundamental and works great. But what if the common parent is five, ten, or twenty components up the tree? Passing a value down through every layer—a process nicknamed “prop drilling”—becomes tedious and makes components harder to reuse. You need a way to make state available globally, without the manual passing.
React’s Context API is designed for this specific problem. It lets you create a global “box” of data that any component in your tree can access, no matter how deeply nested it is. You create a context, wrap part of your component tree in a context Provider that holds the value, and any child can useContext to read it.
I often use this for data that is truly global but relatively simple, like a user’s theme preference or authentication status.
// 1. Create the box (context). We'll put a user object in it.
const UserContext = createContext(null);
function App() {
const [currentUser, setCurrentUser] = useState(null);
// 2. Provide the value to the entire tree inside this provider.
return (
<UserContext.Provider value={{ currentUser, setCurrentUser }}>
<Header />
<MainContent />
<Footer />
</UserContext.Provider>
);
}
function Header() {
// 3. Any component can consume the context, skipping all intermediate parents.
const { currentUser } = useContext(UserContext);
return (
<header>
<h1>My App</h1>
<p>Welcome, {currentUser ? currentUser.name : 'Guest'}</p>
</header>
);
}
Context is excellent for passing down values or functions. However, it’s not a dedicated state management tool. Every time the context value changes, every component that uses that context re-renders. For frequently changing, complex state, this can cause performance issues. We need a more structured, predictable system.
That’s where libraries like Redux come in. Redux is based on a strict pattern: your entire app state lives in a single, immutable JavaScript object called the store. You never directly change this state. Instead, you dispatch plain object “actions” that describe what happened (e.g., { type: 'cart/addItem', payload: product }).
Special functions called “reducers” listen for these actions. They take the current state and the action, and return a brand new state object. This predictability makes debugging easier because every state change is traceable.
// A Redux "slice" for a shopping cart.
import { createSlice, configureStore } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
// "Reducer" function for adding an item.
addItem: (state, action) => {
// With Redux Toolkit, we can write logic that "mutates" state.
// It's safely converted to an immutable update internally.
state.items.push(action.payload);
},
clearCart: (state) => {
state.items = [];
}
}
});
// Extract the auto-generated action creators.
export const { addItem, clearCart } = cartSlice.actions;
// Create the store with this reducer.
const store = configureStore({
reducer: {
cart: cartSlice.reducer
}
});
// In a React component, using React-Redux hooks:
function AddToCartButton({ product }) {
const dispatch = useDispatch();
const handleClick = () => {
// Dispatch the action to update the global store.
dispatch(addItem(product));
};
return <button onClick={handleClick}>Add to Cart</button>;
}
function CartSummary() {
// Select just the data this component needs from the global store.
const cartItems = useSelector((state) => state.cart.items);
const itemCount = cartItems.length;
return <div>Items in cart: {itemCount}</div>;
}
Redux is powerful and has excellent developer tools. But its strict structure can feel like a lot of boilerplate code for smaller projects. Some developers prefer a more “magical” and direct approach.
MobX takes a different philosophy. Instead of requiring you to dispatch actions, it lets you mark your state as “observable.” Then, any part of your UI that depends on that state automatically “reacts” and updates when the state changes. It feels more like writing plain JavaScript classes.
You change state directly, and MobX automatically ensures everything that depends on it is updated efficiently. This can lead to more concise code.
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
// A simple, observable store class.
class TimerStore {
seconds = 0;
constructor() {
makeAutoObservable(this); // Makes all properties observable.
}
// Any method that modifies state is an "action."
increase() {
this.seconds += 1;
}
reset() {
this.seconds = 0;
}
// A "computed" value derives from state.
get minutes() {
return (this.seconds / 60).toFixed(1);
}
}
const myTimer = new TimerStore();
// The `observer` wrapper makes the component react to observable changes.
const TimerDisplay = observer(() => {
return (
<div>
<p>Seconds: {myTimer.seconds}</p>
<p>Minutes: {myTimer.minutes}</p> {/* Updates automatically */}
<button onClick={() => myTimer.increase()}>Add Second</button>
</div>
);
});
For many, MobX feels more intuitive. The trade-off is that because updates are automatic, the flow of data can be less explicit than in Redux. You also lose the built-in action history that Redux DevTools provides.
In recent years, the trend has moved towards lighter, hook-based libraries. Zustand is a prime example. It gives you a way to create a small, focused global store with minimal setup. There’s no providers to wrap your app in; you just create a store and use it directly in your components via a hook.
I find myself reaching for Zustand when I need shared state that’s more complex than Context can handle comfortably, but where Redux feels like overkill.
import create from 'zustand';
// Create a store. The `set` function is used to update state.
const useSettingsStore = create((set) => ({
theme: 'light',
notifications: true,
volume: 80,
// Actions are just functions inside the store.
toggleTheme: () => {
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
}));
},
updateVolume: (newLevel) => {
set({ volume: Math.max(0, Math.min(100, newLevel)) });
}
}));
function SettingsPanel() {
// Select multiple pieces of state. The component only re-renders when these change.
const { theme, volume, toggleTheme, updateVolume } = useSettingsStore();
return (
<div className={`panel-${theme}`}>
<button onClick={toggleTheme}>Toggle Theme</button>
<input
type="range"
min="0"
max="100"
value={volume}
onChange={(e) => updateVolume(Number(e.target.value))}
/>
<span>Volume: {volume}</span>
</div>
);
}
It’s remarkably simple. The store is a custom hook. You get your state and the functions to change it. No reducers, no dispatchers, no providers.
Finally, let’s look at Recoil, a library developed at Facebook (Meta). It’s built specifically for React and tries to solve some granular performance issues. Its core concepts are “atoms” and “selectors.”
An atom is a unit of state. Components can subscribe to atoms. A selector is a piece of derived state—it takes atoms (or other selectors) as input and produces a transformed output. If an atom updates, only components subscribed to that atom or its dependent selectors re-render.
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';
// An atom for our todo list filter.
const todoListFilterState = atom({
key: 'todoListFilterState', // unique key
default: 'all', // initial value
});
// An atom for our list of todo items.
const todoListState = atom({
key: 'todoListState',
default: [],
});
// A selector that derives a filtered list.
const filteredTodoListState = selector({
key: 'filteredTodoListState',
get: ({ get }) => {
const filter = get(todoListFilterState);
const list = get(todoListState);
switch (filter) {
case 'completed':
return list.filter((item) => item.isComplete);
case 'uncompleted':
return list.filter((item) => !item.isComplete);
default:
return list;
}
},
});
function TodoList() {
// This component only re-renders when the filtered list changes.
const filteredTodos = useRecoilValue(filteredTodoListState);
return (
<ul>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} item={todo} />
))}
</ul>
);
}
function TodoListFilters() {
// This component manages the filter atom.
const [filter, setFilter] = useRecoilState(todoListFilterState);
const updateFilter = (e) => {
setFilter(e.target.value);
};
return (
<select value={filter} onChange={updateFilter}>
<option value="all">All</option>
<option value="completed">Completed</option>
<option value="uncompleted">Uncompleted</option>
</select>
);
}
Recoil feels very “React-y.” It integrates seamlessly and solves the problem of expensive re-renders in a granular way. However, as a newer library, its ecosystem is smaller than Redux’s.
So, how do you choose? It’s not about which is best, but which is most appropriate.
Start with local component state. It’s the foundation.
When siblings need to share, lift state up to their parent.
When you need to pass data through many layers, consider Context for simple, low-frequency updates.
For a large app with complex, interconnected state where predictability and debugging are critical, Redux is a robust, time-tested choice.
If you prefer a more object-oriented, automatic style and have less complex state, MobX can be a great fit.
For medium-sized apps or features where you want global state without the ceremony, give Zustand a try.
If you’re building a large React app and are concerned about fine-grained performance for derived state, explore Recoil.
The goal is always the same: to keep the room tidy. Pick the organizational system that fits the size of your room and the way you and your team like to work. You can always start simple and adopt a more structured pattern as your application’s needs grow.