Server state and client state: why React apps need two libraries

The architectural landscape of client-side applications, especially those built with frameworks like React, has evolved significantly. Initially, state management focused primarily on data owned and controlled entirely by the application itself – the client state. Libraries like Redux emerged as powerful solutions to centralize and predict client state changes, bringing order to increasingly complex user interfaces. However, as applications became more interconnected and reliant on external APIs, the challenge of managing data originating from remote servers became a dominant concern. This remote data, which is inherently volatile and external to the client’s direct control, demanded a different approach. The current industry consensus, supported by widespread adoption of specialized tools, underscores that a "one-size-fits-all" approach to state management is inadequate for the sophisticated demands of today’s applications. This critical distinction is not merely an academic point but a practical necessity, especially as applications scale in complexity and integrate into distributed systems, such as those leveraging Module Federation.
The Genesis of State Management Challenges
The journey of state management in client-side development is marked by continuous innovation driven by evolving application needs. In the early days of single-page applications (SPAs), managing UI state across various components quickly became a labyrinthine task. React’s component-based architecture offered a declarative way to build UIs, but local component state (using setState or useState) proved insufficient for global application state. This led to the "prop drilling" problem and the need for more centralized state management patterns.
The introduction of Flux architecture by Facebook and its popular implementation, Redux, provided a robust, predictable container for application state. Redux championed a single, immutable store, with state changes occurring only through pure reducers in response to dispatched actions. This model excelled at managing client-specific data like UI themes, user preferences, form drafts, or authentication status, which are entirely controlled by the application. However, as applications started fetching more and more data from backend APIs, developers began to shoehorn server-originated data into their Redux stores. This practice, while initially functional for small applications, exposed the inherent limitations of Redux when dealing with the asynchronous, often stale, and network-dependent nature of server state. Middleware like Redux Thunk or Redux Saga were introduced to handle side effects, including API calls, but the core Redux store was not purpose-built for the complexities of data fetching and caching.
The increasing prevalence of REST APIs and later GraphQL APIs highlighted a gap. Developers found themselves repeatedly writing boilerplate code for:
- Tracking loading states (
isLoading,isError). - Handling errors and retries.
- Implementing caching mechanisms to prevent redundant network requests.
- Ensuring data freshness and invalidating stale data.
- Deduplicating requests for the same resource.
These repetitive tasks signaled a fundamental mismatch between the requirements of server-originated data and the capabilities of client-state management libraries. This realization spurred the development of a new class of libraries specifically designed to address the challenges posed by server state.
Deconstructing Server State: The Volatile Copy
Server state represents a local, client-side copy of data that fundamentally resides on a remote server. This data is not owned by the client application; rather, the client merely holds a snapshot of it. The crucial implication is that the original data on the server can change at any moment, rendering the client’s local copy potentially stale. This inherent volatility dictates a complex set of requirements for its effective management:
- Caching: To optimize performance and reduce network load, server data must be cached locally. This prevents unnecessary refetches when a user navigates away from and then back to a view that requires the same data. Effective caching strategies include in-memory caches, disk caches, and often involve time-based or event-driven invalidation.
- Deduplication: When multiple components or parts of an application request the same piece of server data concurrently, a server-state management library must intelligently deduplicate these requests, ensuring that only a single network call is made. This prevents redundant requests that can overload the server and consume unnecessary client resources.
- Background Refetching: To ensure data freshness without explicit user action, server state often benefits from automatic background refetching. This can occur on window focus, network reconnection, or at predefined intervals, quietly updating the UI if the underlying server data has changed. This contributes significantly to a responsive and up-to-date user experience.
- Retries: Network instability is a fact of life. Robust server-state management includes automatic retry mechanisms with exponential backoff strategies to handle transient network failures gracefully, improving application resilience.
- Invalidation: After a write operation (e.g., creating a new user, updating a product), related cached data on the client becomes stale and must be invalidated. This triggers a refetch of the affected data, ensuring the UI reflects the most current information. Sophisticated invalidation strategies are crucial for maintaining data consistency.
- Garbage Collection: Over time, the client’s cache can accumulate data that is no longer needed or watched by any active component. Garbage collection mechanisms automatically prune this unused data, freeing up memory and preventing memory leaks.
These features are not "nice-to-haves"; they are fundamental necessities for any application interacting heavily with external APIs. Building these features manually for every API endpoint would be an enormous undertaking, introducing significant boilerplate and potential for errors. This is precisely where specialized server-state libraries shine, abstracting away these complexities behind simple, declarative APIs.
Understanding Client State: The Application’s Domain
In stark contrast to server state, client state is data that the application "owns outright." This data is typically created, managed, and consumed entirely within the client environment. It doesn’t rely on an external source for its truthfulness; whatever the app holds is, by definition, correct within its scope. Examples of client state include:

- UI-specific state: Whether a modal dialog is open or closed, the current tab selected in a navigation component, the visibility of a tooltip.
- Form drafts: User input in forms that hasn’t yet been submitted to a server.
- User preferences: The selected theme (dark/light mode), preferred language, layout settings.
- Authentication tokens: While these are received from a server, their secure storage and management within the client (often in secure storage rather than a global JS state store) fall under client-side concerns, impacting UI elements like user menus.
- Selected items: A chosen account in a dropdown, a sort order applied to a list, items added to a shopping cart before checkout.
The requirements for managing client state are considerably simpler:
- Storage: It needs a place to be held.
- Reactivity: Components need to be able to react to changes in this state.
- Persistence (optional): Some client state, like user preferences or authentication tokens, might need to survive application restarts, often achieved through local storage or secure device storage.
Crucially, client state does not suffer from staleness in the same way server state does. There’s no external "original" to compare against, hence no need for caching, background refetching, deduplication, or complex invalidation logic. A library built for client state focuses on providing a performant and predictable way to update and observe data within the application’s boundaries.
The Peril of Undifferentiated State Management
The primary mistake developers make is to treat server state and client state as degrees of the same thing, attempting to manage both using a single, general-purpose state library. While a Redux store with redux-thunk might technically work for API calls, it forces developers to rebuild sophisticated server-state machinery by hand. This includes:
- Manually tracking
loadinganderrorstates for each request. - Implementing custom caching logic, often prone to bugs and difficult to invalidate correctly.
- Developing bespoke deduplication strategies.
- Writing boilerplate for retries and background refreshes.
This approach is manageable for small applications with limited data fetching. However, as an application grows, this manual effort quickly becomes a significant burden, essentially turning into a "second job" of maintaining a custom, often inferior, data-fetching layer. It diverts resources from core feature development and introduces a high risk of inconsistencies and performance issues. Conversely, attempting to manage simple client state with a heavy, feature-rich server-state library would introduce unnecessary overhead and complexity for tasks that require minimal machinery.
Specialized Tooling: The Path to Efficiency
The industry has converged on a pattern of using specialized tools for each state type, leading to more efficient development and robust applications. Two prominent pairings exemplify this approach:
1. Redux Toolkit (RTK) and RTK Query: The Integrated Ecosystem
For teams already deeply embedded in the Redux ecosystem, the combination of Redux Toolkit (RTK) and RTK Query offers a streamlined, "batteries-included" solution.
- Redux Toolkit (RTK): This is the official opinionated solution for efficient Redux development. It simplifies Redux setup, reduces boilerplate, and provides utilities like
createSlicefor defining client state reducers and actions with minimal code. For instance, acreateSlicefor managing a user’s theme would be concise and straightforward, solely focused on holding and updating thethemevalue. - RTK Query: Built on top of Redux Toolkit, RTK Query is a powerful data-fetching and caching library specifically designed for server state. It automatically handles all the complex aspects of data fetching:
- Declarative API: Developers define API endpoints using a simple builder pattern, specifying queries (for fetching data) and mutations (for sending data).
- Automatic Caching and Deduplication: RTK Query manages an intelligent cache, ensuring that identical requests are deduplicated and that data is served from the cache when available.
- Request Lifecycle Management: It automatically tracks loading, success, and error states, providing hooks like
useGetProfileQuerythat expose this information directly to components. - Invalidation and Refetching: It supports cache invalidation based on "tags," allowing developers to mark specific types of data as stale after a mutation, triggering automatic refetches where needed.
- Optimistic Updates: Facilitates optimistic UI updates, enhancing user experience by immediately reflecting changes while the server request is in flight.
With RTK and RTK Query, developers maintain a single Redux store, but server state and client state are handled by distinct, purpose-built tools within it. Client state resides in plain createSlice slices, while server state is managed by RTK Query’s dedicated layer, leveraging its comprehensive caching and lifecycle machinery.
// RTK Query for server state
import createApi, fetchBaseQuery from '@reduxjs/toolkit/query/react';
export const api = createApi(
reducerPath: 'api',
baseQuery: fetchBaseQuery( baseUrl: '/api' ),
endpoints: builder => (
getProfile: builder.query<Profile, void>(
query: () => '/users/me',
// tagTypes for invalidation
providesTags: ['Profile'],
),
updateProfile: builder.mutation<Profile, Partial<Profile>>(
query: (body) => (
url: '/users/me',
method: 'PATCH',
body,
),
// Invalidates 'Profile' tag after successful update
invalidatesTags: ['Profile'],
),
),
);
export const useGetProfileQuery, useUpdateProfileMutation = api;
// In a component:
const data: profile, isLoading, error = useGetProfileQuery();
// Redux Toolkit createSlice for client state
import createSlice from '@reduxjs/toolkit';
const settingsSlice = createSlice(
name: 'settings',
initialState: theme: 'system', notificationsEnabled: true ,
reducers:
setTheme: (state, action) =>
state.theme = action.payload;
,
toggleNotifications: (state) =>
state.notificationsEnabled = !state.notificationsEnabled;
,
,
);
export const setTheme, toggleNotifications = settingsSlice.actions;
export default settingsSlice.reducer;
// In a component:
const theme = useSelector((state) => state.settings.theme);
const dispatch = useDispatch();
const handleThemeChange = (newTheme) => dispatch(setTheme(newTheme));
2. TanStack Query (React Query) and Zustand: The Modular Approach
For fresh applications without an existing Redux commitment, or for developers who prefer a more decoupled and lightweight approach, the pairing of TanStack Query (formerly React Query) and Zustand offers compelling advantages.

-
TanStack Query: This library is a powerful, framework-agnostic (though most commonly used with React) data-fetching tool that manages server state entirely outside of a traditional global store. It maintains its own sophisticated cache client, handling:
- Automatic Refetching: Queries automatically refetch on window focus, network reconnect, and optional intervals.
- Stale-While-Revalidate: A common caching strategy where cached data is immediately displayed while a fresh request is made in the background.
- Query Keys: Uses a unique
queryKeyarray to identify and manage cached data, enabling granular control over invalidation and refetching. - Mutations: Provides robust tools for managing data mutations, including optimistic updates and automatic query invalidation.
-
Zustand: A minimalist, fast, and scalable state management solution for client state. It’s known for its simplicity, using a small API based on React hooks. Zustand stores are created outside of React components and can be consumed directly, offering excellent performance and a low learning curve. It’s ideal for holding simple client-side values without the overhead of larger state management solutions.
This pairing splits state management across two libraries, with TanStack Query managing the server cache independently, and Zustand handling the lightweight client state. This modularity can lead to smaller bundle sizes if Redux is not otherwise required, and offers a highly flexible architecture.
// TanStack Query for server state
import useQuery, QueryClient, QueryClientProvider from '@tanstack/react-query';
const queryClient = new QueryClient();
async function getProfile()
const response = await fetch('/api/users/me');
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
export const useProfile = () =>
useQuery( queryKey: ['profile'], queryFn: getProfile );
// In a component:
const data: profile, isLoading, error = useProfile();
// To invalidate: queryClient.invalidateQueries( queryKey: ['profile'] );
// Zustand for client state
import create from 'zustand';
interface SettingsState 'dark') => void;
toggleNotifications: () => void;
export const useSettings = create<SettingsState>()((set) => (
theme: 'system',
notificationsEnabled: true,
setTheme: (theme) => set( theme ),
toggleNotifications: () => set((state) => ( notificationsEnabled: !state.notificationsEnabled )),
));
// In a component:
const theme = useSettings((state) => state.theme);
const setTheme = useSettings((state) => state.setTheme);
const handleThemeChange = (newTheme) => setTheme(newTheme);
Both pairings are highly effective because they acknowledge and cater to the distinct needs of server and client state, avoiding the pitfalls of a monolithic state management approach.
Choosing the Right Split: A Strategic Decision
There is no universally "best" pairing; the optimal choice depends on several factors:
- Existing Ecosystem: If a team is already heavily invested in Redux, leveraging RTK and RTK Query minimizes context switching and re-tooling.
- Project Scale and Complexity: For small applications with minimal API interaction, a single Redux store with thunks might suffice initially. However, developers should be vigilant for the moment they start writing custom caching or invalidation logic – that is the clear signal to adopt a specialized server-state library.
- Team Familiarity and Preferences: If the team is comfortable with the Redux paradigm, RTK Query provides a natural extension. If a more lightweight, hook-centric approach is preferred, TanStack Query and Zustand offer excellent modularity.
- Performance Requirements: While both solutions are highly performant, some teams might prefer the external cache of TanStack Query for its specific optimizations or the minimal footprint of Zustand for client state.
Industry trends, as observed in developer surveys (e.g., State of JS, Stack Overflow Developer Survey data), increasingly indicate a strong preference for specialized data-fetching libraries like TanStack Query and RTK Query. This shift reflects a growing understanding that efficient server state management is a distinct and complex problem that warrants dedicated solutions, moving beyond the traditional role of client-side state containers.
Broader Implications: Micro-Frontends and Distributed Architectures
The distinction between server and client state becomes even more critical and challenging in advanced architectural patterns like Module Federation. In a micro-frontend or micro-app setup, where different parts of an application (federated modules) are developed and deployed independently but loaded at runtime into a single shell application, state coordination is a significant hurdle.
When multiple teams contribute to a larger application through federated modules, questions arise:
- Whose cache is authoritative? If two modules independently fetch the same user profile, should they each maintain their own cache, or should there be a shared, centralized cache for server state?
- How is data consistency maintained across modules? If one module updates a user’s status, how do other modules consuming that user’s data get updated reliably and promptly?
- How do client states interact? While client state is typically local to a module, there might be global client preferences (e.g., language) that need to be shared.
A clear separation of server and client state, managed by their respective specialized tools, provides a robust foundation for addressing these challenges. A well-designed system might centralize server state caching in the shell application or a designated "data module," ensuring a single source of truth for remote data across all federated modules. Individual modules can then manage their specific client state (e.g., local form data, UI toggles) using lightweight solutions like Zustand, without interfering with the global server state cache. This architectural clarity minimizes coordination overhead, reduces conflicts, and enhances the overall maintainability and scalability of distributed applications. The upcoming discussion on how RTK Query’s cache tags and TanStack’s query keys handle invalidation will further highlight how these subtle API differences profoundly impact multi-team development workflows.
In conclusion, recognizing the fundamental differences between server state and client state is not merely an optimization; it is a foundational principle for modern application development. By adopting specialized libraries like RTK Query/TanStack Query for server state and Redux Toolkit/Zustand for client state, developers can significantly reduce boilerplate, improve performance, enhance user experience, and build applications that are more resilient, maintainable, and scalable, especially as they evolve into complex, distributed systems. The era of a single, monolithic state management solution for all data types is definitively over; the future lies in intelligent, purpose-built tools.







