Tools evolve, trends change.
React introduced developers to a new way of thinking about UI, where components react to data and user interactions in real time. It started as a simple idea: manage state locally and let the UI update automatically. But as applications grew more complex, so did the challenge of handling state that needed to live across many parts of an app.
Fast forward to currentDate()
, and we’ve come a long way from simple useState
and useEffect
for state management and data fetching respectively. Today, React developers work with Context API
, Zustand
, Redux Toolkit
, for managing state and TanStack Query
, RTK Query
, server side fetching(Nextjs) for querying or mutating data. Each tool solves a slightly different problem, and knowing which one to use for a particular project makes all the difference.
In this article, we’ll take a detailed look at modern state management, data query and mutation in React. You’ll learn when each tool shines, how they fit together, and how to make the most of them in a modern React app.
Table of Contents
- Understanding React’s Core State Model
- Context API: The Native Option
- Zustand: Lightweight Yet Scalable
- Redux Toolkit: Predictable and Enterprise-Ready
- useEffect: The Misused Hook
- TanStack Query
- RTK Query
- ServerSide Fetching
- Patterns and Best Practices
- Conclusion
1. Understanding React’s Core State Model
Before looking at the aforementioned contemporary tools, it’s worth revisiting the basics.
React’s design encourages predictable, unidirectional data flow. Each component has its own state, typically managed with hooks like useState
and useReducer
.
Usestate
const [name, setName] = useState("Nugar");
This works perfectly for local, isolated state such as toggling a modal or tracking form input.
For more complex scenarios, developers often use useReducer for clearer state transitions:
UseReducer
const initialState = { isOpen: false, modalType: null };
function reducer(state, action) {
switch (action.type) {
case "OPEN_MODAL": return { isOpen: true, modalType: action.payload };
case "CLOSE_MODAL": return { isOpen: false, modalType: null };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
The problem arises when different components need access to the same data. Manually passing props up and down the tree leads to “prop drilling,” which quickly becomes difficult to maintain. This is where global state solutions step in.
2. Context API: The Native Option
The Context API is React’s built-in way to share data globally across components without prop drilling. It’s simple, dependency-free, and ideal for small applications or simple global state.
// theme-context.tsx
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext(null);
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
const toggleTheme = () => setTheme(t => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
Then in any component:
const { theme, toggleTheme } = useTheme();
When to Use
- Small projects or simple global state
- Shared values like theme, language, or authentication status
Limitations
- Can trigger re-renders across all consuming components
- Becomes hard to manage as the app grows
- Not ideal for frequent or performance-sensitive updates
The Context API shines for lightweight scenarios, but once your application grows or requires complex state logic, a dedicated library becomes a better fit.
3. Zustand: Lightweight Yet Scalable
Zustand
has become a favorite among developers for its simplicity and performance. It provides a minimal, intuitive API that lets you create global stores without providers or reducers. I frequently use Zustand nowadays for projects that require global state management due to its simplicity and ease of use.
import { create } from "zustand";
const useUserStore = create(set => ({
user: null,
setUser: user => set({ user }),
logout: () => set({ user: null })
}));
Usage
const { user, setUser, logout } = useUserStore();
No boilerplate, no extra setup. Zustand uses shallow comparison to prevent unnecessary re-renders, keeping your app fast and efficient.
Why Developers Love It
- Minimal API and easy learning curve
- Excellent TypeScript support
- Works great for UI and session state
- Supports persistence, middlewares, and subscriptions
When to Use
- Small to medium projects that need global state
- Applications with local caching or session management
4. Redux Toolkit: Predictable and Enterprise-Ready
Redux
has been around for years, and while it used to be associated with boilerplate and verbosity, Redux Toolkit (RTK) changed that. RTK provides structured, opinionated utilities that simplify reducers, actions, and store configuration.
import { configureStore, createSlice, PayloadAction } from "@reduxjs/toolkit";
interface AuthState {
user: { name: string } | null;
}
const initialState: AuthState = { user: null };
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
login: (state, action: PayloadAction<{ name: string }>) => {
state.user = action.payload;
},
logout: state => {
state.user = null;
},
},
});
export const { login, logout } = authSlice.actions;
export const store = configureStore({ reducer: { auth: authSlice.reducer } });
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Usage
import { useSelector, useDispatch } from "react-redux";
import { login, logout, RootState } from "./store";
export default function App() {
const user = useSelector((state: RootState) => state.auth.user);
const dispatch = useDispatch();
return user ? (
<button onClick={() => dispatch(logout())}>Logout</button>
) : (
<button onClick={() => dispatch(login({ name: "Rahman" }))}>Login</button>
);
}
5. useEffect: The Misused Hook
Before exploring modern data-fetching solutions, it’s important to talk about useEffect
, a hook that was never designed for fetching or mutating data, yet became the default tool for it in most React apps.
When React Hooks were introduced, useEffect
filled an essential gap in functional components. It allowed developers to perform side effects; actions that happen outside React’s rendering cycle. These effects include subscribing to events, updating the document title, managing timers, or synchronizing state with browser APIs.
In simple terms, useEffect
lets your component “reach outside” React’s pure rendering world and interact with the environment.
useEffect(() => {
document.title = `Hello, ${userName}`;
}, [userName]);
This is a perfect use case: the effect reacts to changes in userName
and synchronizes the browser title accordingly.
However, over time, developers began using useEffect
for something it wasn’t built for — data fetching and mutation.
useEffect(() => {
async function loadUser() {
const res = await fetch("/api/user");
const data = await res.json();
setUser(data);
}
loadUser();
}, []);
While this pattern works for small applications, it quickly becomes fragile as your app grows.
Why useEffect Is a Poor Fit for Data Operations
- It Easily Re-Triggers Requests
- It Doesn’t Handle Caching or Syncing
- It Causes Race Conditions
- It Adds Manual Overhead
These problems have even caused real-world incidents. Recently, Cloudflare experienced a large-scale performance issue that stemmed from an application repeatedly firing API calls due to misused React effects. The component’s re-renders multiplied outgoing requests, consuming massive internal bandwidth. The problem wasn’t React, it was a hook used for something beyond its purpose.
What useEffect
Was Actually Meant For
The React team’s intention for useEffect has always been clear:
- Setting up subscriptions or event listeners
- Managing timers, intervals, or animations
- Interacting with browser or third-party APIs
- Cleaning up resources when a component unmounts
These are side effects, they are interactions that depend on or modify something outside of React’s data flow.
Fetching data, however, is a data flow operation, not a side effect. It belongs in a layer that manages caching, background refetching, synchronization, and invalidation. That’s far beyond what useEffect was built to handle.
Modern Tools Do It Differently
Libraries like React Query, RTK Query, and frameworks like Next.js have redefined how React handles data operations.
These tools aren’t built on top of useEffect
. Instead, they use dedicated data layers that integrate deeply with React’s rendering engine, concurrent features, and Suspense. They know when to fetch, how to cache, and when to revalidate, all without you manually orchestrating it.
In modern React, useEffect
should be reserved for what it was meant to do: handling external side effects at the component level. For data fetching, the ecosystem has evolved, and the right tools now exist to handle that responsibility cleanly and efficiently.
6. TanStack Query: Data Fetching Made Declarative
TanStack Query formerly known as React Query introduced a fundamental shift in how developers think about server data. Instead of manually managing loading, error, and caching logic, React Query abstracts those concerns behind a declarative API. It treats data as a cache that stays in sync with the server, not as temporary state inside your component.
TanStack Query revolves around two main concepts:
- Queries for reading data (fetching)
- Mutations for writing or updating data (creating, editing, deleting) Both are managed within a powerful caching layer that automatically keeps your UI in sync with your backend.
Query Example
import { useQuery } from "@tanstack/react-query";
function UserProfile() {
const { data, isLoading, isError } = useQuery({
queryKey: ["user"],
queryFn: async () => {
const res = await fetch("/api/user");
if (!res.ok) throw new Error("Failed to fetch user");
return res.json();
},
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Something went wrong.</p>;
return <h2>Welcome back, {data.name}</h2>;
}
This hook handles fetching, caching, and background refetching automatically. When the queryKey
changes, React Query knows it’s a different dataset and fetches accordingly.
Mutation Example
import { useMutation, useQueryClient } from "@tanstack/react-query";
function UpdateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (user) => {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
if (!res.ok) throw new Error("Failed to update user");
return res.json();
},
onSuccess: () => {
// Automatically refresh user data
queryClient.invalidateQueries(["user"]);
},
});
const handleUpdate = () => {
mutation.mutate({ name: "Rahman Nugar" });
};
return (
<button onClick={handleUpdate} disabled={mutation.isPending}>
{mutation.isPending ? "Updating..." : "Update Profile"}
</button>
);
}
TanStack Query handles retries, optimistic updates, and cache synchronization under the hood, freeing you from manually tracking loading or error states.
Why It Matters
- Queries cache data and refresh it only when needed.
- Mutations update the server and sync local caches automatically.
- Background updates ensure your data stays fresh without blocking UI rendering.
7. RTK Query: Redux-Powered Data Management
While TanStack Query is a standalone solution, RTK Query extends the Redux Toolkit ecosystem with an integrated data-fetching layer. It’s ideal if you already use Redux for state management but want modern, efficient data handling without extra libraries.
RTK Query provides similar declarative querying and mutation features but ties them directly into your Redux store.
Setting Up RTK Query
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (builder) => ({
getUser: builder.query({
query: () => "/user",
}),
updateUser: builder.mutation({
query: (user) => ({
url: "/user",
method: "PUT",
body: user,
}),
}),
}),
});
export const { useGetUserQuery, useUpdateUserMutation } = userApi;
Integrate it into your Redux store:
import { configureStore } from "@reduxjs/toolkit";
import { userApi } from "./userApi";
export const store = configureStore({
reducer: {
[userApi.reducerPath]: userApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(userApi.middleware),
});
Using Queries and Mutations
function User() {
const { data, isLoading } = useGetUserQuery();
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
if (isLoading) return <p>Loading user...</p>;
return (
<div>
<h2>{data.name}</h2>
<button
onClick={() => updateUser({ name: "Rahman Nugar" })}
disabled={isUpdating}
>
{isUpdating ? "Updating..." : "Update"}
</button>
</div>
);
}
RTK Query automatically caches responses, invalidates old data after mutations, and deduplicates concurrent requests while keeping data in Redux for predictable debugging and inspection.
When to Use RTK Query
- You’re already using Redux Toolkit.
- You want a single source of truth for both app and server state.
- You need integrated caching and request lifecycle management.
8. ServerSide Fetching(Nextjs)
With frameworks like Next.js, data fetching moved beyond the client. Instead of fetching data after rendering, Next.js introduced ways to prefetch data on the server through getServerSideProps
, getStaticProps
, and, more recently, the Server Components model.
Server fetching shifts the data-fetching responsibility to the server layer, improving performance, SEO, and initial load times.
Example with Server Components
In the App Router (app/
directory), you can fetch data directly on the server without using useEffect or any client-side hooks:
// app/page.tsx
async function getUser() {
const res = await fetch("https://api.example.com/user", {
cache: "no-store",
});
return res.json();
}
export default async function Page() {
const user = await getUser();
return (
<section>
<h1>Welcome, {user.name}</h1>
</section>
);
}
Mutations in Next.js
With the new Server Actions, Next.js now supports secure, server-side mutations as well:
"use server";
export async function updateUser(data: { name: string }) {
await fetch("https://api.example.com/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
These actions can be directly called from client components using forms or event handlers, eliminating API route overhead while maintaining security and performance.
When to Use
- Apps with strong SEO needs or public-facing content
- Projects that rely on fast, server-rendered pages
- Scenarios where data privacy and server security matter
9. Patterns and Best Practices
1. Separate UI State from Server State
Keep client-side UI logic (like modals or form inputs) separate from server-synced data.
2. Use useEffect Only for True Side Effects
Reserve it for browser interactions, subscriptions, or third-party integrations not data fetching.
3. Leverage Caching Layers
Tools like React Query and RTK Query prevent unnecessary requests and ensure consistent data.
4. Server-First Approach
In frameworks like Next.js, fetch data server-side whenever possible for better performance and SEO.
5. Handle Mutations with Invalidation
Always invalidate or update cached queries after a successful mutation to keep UI and server data aligned.
10. Conclusion
State management and data operations are essential parts of building efficient and scalable React applications. While React’s built-in tools like Context and hooks such as useState or useReducer can handle smaller projects, more complex applications often need advanced solutions to manage global state, server state, and API interactions efficiently.
For state management, libraries such as Redux Toolkit, Zustand provide robust and predictable ways to manage application-wide state. They help keep your logic organized and predictable, especially when multiple components need access to shared data or when actions in one part of the app affect another.
When it comes to data fetching and mutations, tools like Tanstack Query and RTK Query simplify handling asynchronous operations, caching, and synchronization between the client and server.
There are, of course, many other tools and technologies that address state management and data operations in React for example, libraries like Jotai, MobX, SWR, or even broader technologies like GraphQL that redefine how the client side communicate with the server side.
That being said, the tools covered here represent the most widely adopted solutions in modern React development and the ones I’ve personally worked with in real-world projects to deliver consistent, performant, and maintainable applications.
Top comments (0)