As React applications grow in complexity, one of the most critical challenges developers face is effective state management. Through my experience designing and building React applications, I've refined a strategic approach that balances simplicity, scalability, and developer experience.
In this article, I'll share my three-tiered strategy for state management, which combines global libraries (like Redux or Zustand), React Context for semi-local state, and the built-in useState/useReducer hooks for local state.
This methodology was forged in the fires of a real-world project: a heavy-data Management System frontend. The application, packed with complex tables, frequently updated datasets, and intricate forms, demanded a solution that was both robust and maintainable. The strategy I developed to meet those demands is what I'll outline for you today.
The Three-Tiered Approach
The strategy is simple and follows a clear hierarchy:
- Global State (like Redux, Zustland or any other of your choice): Application-wide data that needs to be accessed across the app
- Semi-Local State (Context): Data shared within a specific section of the application
- Local State (useState/useReducer): Component-specific state that doesn't need to be shared
Ok, lets dive little deeper
First tier of the three tier approach is Global State.
This will the global store for application-wide state that needs to be accessed from various components across the app, here we also store our fetched data. For example fetching and storing data of our Management system I talked about earlier (Libraries like React Query or SWR are excellent choices for handling server-state fetching, caching, and synchronization, often reducing or even eliminating the need for a traditional global store for this type of data). For this we can use a state management library. While Redux has been the traditional choice, I personally prefer Zustand for its simplicity and minimal boilerplate. However, both are excellent choices depending on your needs and preferences.
Little code example:
In this example we are fetching leads and storing and showing on page using table.
import create from 'zustand';
const useLeadStore = create((set) => ({
leads: {},
leadIds: [],
loading: false,
error: null,
// Fetch leads from API and store them
fetchLeads: async (apiUrl) => {
set({ loading: true, error: null });
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error('Failed to fetch leads');
const data = await response.json();
// Assuming data is an array of leads
set((state) => {
const leads = { ...state.leads };
const leadIds = new Set(state.leadIds);
data.forEach((lead) => {
leads[lead.id] = lead;
leadIds.add(lead.id);
});
return {
leads,
leadIds: Array.from(leadIds),
loading: false,
};
});
} catch (error) {
set({ loading: false, error: error.message });
}
},
// you can also have more functions like updating, removing etc.
clearLeads: () => set({ leads: {}, leadIds: [] }),
}));
export default useLeadStore;
Another example, is auth, used all over the app
import create from 'zustand';
const useAuthStore = create((set) => ({
user: null, // User information object
loading: false,
error: null,
// Login action
login: async (credentials, loginApi) => {
set({ loading: true, error: null });
try {
const response = await fetch(loginApi, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
set({
user: data.user,
loading: false,
error: null,
});
} catch (error) {
set({
loading: false,
error: error.message,
});
}
},
// Logout action
logout: () => set({ user: null, token: null, error: null }),
// Check if user is authenticated
isAuthenticated: () => Boolean(get().token),
}));
export default useAuthStore;
I think from these example you might get a clear picture what I want to say.
Second tier of the three is Semi-Local State with Context
For state that needs to be shared within a specific part of your application (like a form, a set of related components, or a feature module) or DOM tree, React Context is the perfect solution. It avoids polluting the global store while providing easy access to shared state within a defined scope and also can help me avoiding ugly prop drills.
A good example for this is to manage column resizing and freezing provides a clean way to share and update this UI state within the table component tree without cluttering global state or passing props through multiple levels. The context holds the current column widths and frozen columns, along with functions to update these. This allows any header or cell component within the provider to access and modify the resizing and freezing logic seamlessly, enabling coordinated UI behavior and avoiding prop drilling.
import React, { createContext, useContext, useState } from 'react';
const ColumnContext = createContext();
export function ColumnProvider({ children }) {
const [columnWidths, setColumnWidths] = useState({}); // { colId: width }
const [frozenColumns, setFrozenColumns] = useState(new Set()); // Set of frozen colIds
const resizeColumn = (colId, width) => {
setColumnWidths((prev) => ({ ...prev, [colId]: width }));
};
const toggleFreeze = (colId) => {
setFrozenColumns((prev) => {
const updated = new Set(prev);
if (updated.has(colId)) updated.delete(colId);
else updated.add(colId);
return updated;
});
};
return (
<ColumnContext.Provider
value={{ columnWidths, frozenColumns, resizeColumn, toggleFreeze }}
>
{children}
</ColumnContext.Provider>
);
}
export function useColumnContext() {
return useContext(ColumnContext);
}
Third and final tier of three is Local State with useState/useReducer
For state that's truly local to a single component (or a small set of tightly coupled components), we can simple use useState or useReducer, which ever suits your use case. For example, form inputs, or any data that doesn't need to be shared across the application.
Why this approach could be a better one
- Performance: Components only subscribe to the state they need
- Separation of Concerns: Different types of state have clear homes
- Maintainability: The purpose of each state is immediately clear
- Scalability: Easy to refactor as requirements change
Common Pitfalls to Avoid
- Don't put everything in global state "just in case"
- Avoid creating Context for every small piece of state—it can make your component tree messy
- Remember that global state and Context are not mutually exclusive—they can work together
- You need to find the optimal boundary for diving the state.
Final thoughts
By strategically implementing this three-tiered approach—global libraries for app-wide data, Context for feature-specific state, and use state for local UI concerns—you can build React applications that are not only highly performant but also maintainable and scalable.
If you found this article helpful, feel free to:
- Follow me on GitHub for more code examples and projects: Github
- Connect with me on LinkedIn to discuss all things React and frontend development: Linkedin
Happy coding
Top comments (0)