DEV Community

Cover image for Contemporary State Management and Data Operations in React
Rahman Nugar
Rahman Nugar

Posted on

Contemporary State Management and Data Operations in React

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

  1. Understanding React’s Core State Model
  2. Context API: The Native Option
  3. Zustand: Lightweight Yet Scalable
  4. Redux Toolkit: Predictable and Enterprise-Ready
  5. useEffect: The Misused Hook
  6. TanStack Query
  7. RTK Query
  8. ServerSide Fetching
  9. Patterns and Best Practices
  10. 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");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Then in any component:

const { theme, toggleTheme } = useTheme();
Enter fullscreen mode Exit fullscreen mode

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 })
}));
Enter fullscreen mode Exit fullscreen mode

Usage

const { user, setUser, logout } = useUserStore();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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();
}, []);
Enter fullscreen mode Exit fullscreen mode

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.

Cloudflare Blogpost on useEfffect

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:

  1. Queries for reading data (fetching)
  2. 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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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)