When two or more components need the same state for business logic or decide which component to show on the screen, it is time to look for some state management solution. In some cases, a regular context or reducer api will be enough. They are the built-in solution for sharing state across components. Other way is using a external store library, or creating your own implementation with useSyncExternalStore() func. Lets see what are the differences and when to use which one.
Internal Stores are managed by the react tree itself. External Stores are managed outside of the react tree, usually in a global state container. So the main difference is, when the state changes, in internal stores, the components that are consuming the state will re-render based on the react's reconciliation algorithm. In external stores, the state change can be detected by subscribing to the store, and only the components that are subscribed to the specific state will re-render.
This is a simple case to implement different store systems. We have 2 states to store global. To solve the state management problem, I introduced the context api and write the code.
import { createContext, useContext, useState } from "react";
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState({ name: "John", email: "john@example.com" });
const [settings, setSettings] = useState({ theme: "dark", language: "en" });
return (
<AppContext.Provider value={{ user, setUser, settings, setSettings }}>
{children}
</AppContext.Provider>
);
}
const useAppProvider = () => useContext(AppContext);And used the hook in different components like this
export const SettingsBody = () => {
const { settings } = useAppProvider();
// Other codes
};
export const UserCard = () => {
const { user } = useAppProvider();
// Other codes
};In the current implementation, any change in the global state will cause all components that consume this state to re-render. For example, if the user state changes, even though the SettingsBody component is not using the user state, it will rerender too.
But, we can solve this this by a pattern, called Context Splitting
This approach suggests using different Context Providers for unrelated states. In our case we can apply that easily!
import { createContext, useContext, useState } from "react";
const SettingsContext = createContext();
const UserContext = createContext();
function AppProvider({ children }) {
const [settings, setSettings] = useState({ theme: "dark", language: "en" });
const [user, setUser] = useState({ name: "John", email: "john@example.com" });
return (
<UserContext.Provider value={{ user, setUser }}>
<SettingsContext.Provider value={{ settings, setSettings }}>
{children}
</SettingsContext.Provider>
</UserContext.Provider>
);
}
const useSettingsProvider = () => useContext(SettingsContext);
const useUserProvider = () => useContext(UserContext);By doing this, we ensure that the SettingsBody component is not rerendered when user state change or vice versa.
We can split the contexts of setters and values as well. So if the component is not using the user state but setUser function will not be rerendered after user state changed.
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={setUser}>
{children}
</UserActionsContext.Provider>
</UserContext.Provider>We fixed the unnecessary rerendering issue for SettingBody and UserCard components. But there is still one more problem left, the lack of granular selection of state properties.
export const EmailCard = () => {
const { email } = useUserProvider();
// Other codes
};
export const BadgeCard = () => {
const { badge } = useUserProvider();
// Other codes
};The example above shows that even after splitting the contexts, we are not able to achieve granular selection of state properties. All components consuming the user context will rerender when any property of the user providercs states changes.(The BadgeCard rerender if the email state change)
We might try a solution like
export const useUserEmail = () => {
const { email } = useContext(UserContext);
return { email };
};
export const useUseName = () => {
const { name } = useContext(UserContext);
return { name };
};But this will not solve the issue, we separated the usages with hooks but we still use the useContext() func that does not have the granular selection
Unfortunately, with the Context API alone, we cannot achieve this level of selection without a third party library.
Zustand , Redux, Jotai, Recoil are some of the popular external store libraries. They provide a way to manage global state outside of the React component tree, allowing for more granular control over state updates and re-renders. Lets see how we can implement the same example with Zustand.
import { create } from "zustand";
const useStore = create((set) => ({
user: { name: "John", email: "john@example.com" },
settings: { theme: "dark", language: "en" },
setUser: (user) => set({ user }),
setSettings: (settings) => set({ settings }),
}));function UserProfile() {
const user = useStore((state) => state.user);
//Other codes
}
function SettingsPanel() {
const settings = useStore((state) => state.settings);
const setSettings = useStore((state) => state.setSettings);
//Other codes
}function EmailCard() {
const email = useStore((state) => state.user.email);
//Other codes
}The above examples solves the unnecessary rerender problem without the need for context splitting. Also, selections are granular!
Also zustand has a beautiful feature persist which makes your states persistent between sessions! (actually, not related to our topic :))
We can use zustand, but to understand how it works, let's create a custom external store like zustand. We need a store that lives outside React:
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState: (newState) => {
state = { ...state, ...newState };
// Notify all subscribers
listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
listeners.add(listener);
// Return unsubscribe function
return () => listeners.delete(listener);
},
};
}Now, we will use useSyncExternalStore hook to bind the store
import { useSyncExternalStore, useRef } from "react";
const store = createStore({
user: { name: "John", email: "john@example.com" },
settings: { theme: "dark", language: "en" },
});
function useStore(selector) {
const selectedState = useRef();
return useSyncExternalStore(
(callback) => {
return store.subscribe(() => {
const newState = selector(store.getState());
//this is the granular selection
if (!Object.is(selectedState.current, newState)) {
selectedState.current = newState;
callback();
}
});
},
() => selector(store.getState()),
() => selector(store.getState()) // To tell ssr which state to use,
);
}
// Helper to update state
export const updateStore = (updates) => store.setState(updates);The implementation uses Object.is for comparison, which checks reference equality. For deep equality or custom comparison, we'd need additional logic like Zustand's shallow comparison. For the purpose of this article, we'll keep it simple with reference equality!
And use the hook in our components
function UserProfile() {
// Only subscribes to user changes
const user = useStore((state) => state.user);
// Other codes
}In terms of bundle size, context is free because it is built-in. Zustand is relatively small but adds some overhead (~about 3KB minified+gzipped). If bundle size is a concern, context might be the better choice for a very small app.
For performance, React 19's compiler can auto-memoize components, reducing the cost of rerenders from Context. However, Context will still trigger rerenders for all consumers when its value changes - the compiler just makes those rerenders cheaper.
The real deal is granular selection. With external stores, we can achieve this level of selection without any issues. And this can lead to significant performance improvements in large applications with complex state management needs.
Context is tied to React's component tree. External stores live independently and only notify React when necessary. The granular selection is a very powerful feature that can lead to better performance in complex apps. If your app has simple state needs and you want to avoid extra dependencies, Context is fine. In the end, the performance depends on how you use them. I prefer external stores as they give more power (like persist feature), and also they are not react dependent.