Skip to main content

🔄 State Management Beyond React – A Complete Guide

React makes it easy to manage local component state with useState and useReducer. But as apps grow, so does the complexity of state. Passing data through multiple layers of components (prop drilling) becomes frustrating. That’s where state management patterns and libraries beyond React come in.

In this blog, we’ll cover when to lift state, when to use global stores, and explore popular tools like Redux Toolkit, React Query, Zustand, MobX, and even a Context + Reducer pattern.

1. When to Move State Up vs External Store

Definition:
Moving state up → Keeping shared state in a common parent component to avoid prop drilling.

External store (global state) → Keeping state outside React components (e.g., Redux, Zustand) so multiple parts of the app can access it directly.

Explanation:
Use lifting state up if only a few components need the data.
Use an external store if many unrelated parts of the app need the same state (e.g., user authentication, theme, shopping cart).


Example (Prop Drilling ❌):

function App() {
  const [theme, setTheme] = React.useState("dark");
  return <Toolbar theme={theme} />;
}

function Toolbar({ theme }) {
  return <ThemedButton theme={theme} />;
}

function ThemedButton({ theme }) {
  return <button>Theme is {theme}</button>;
}

Better: Use Context (Global State ✅)
const ThemeContext = React.createContext();

function App() {
  const [theme] = React.useState("dark");
  return (
    <ThemeContext.Provider value={theme}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function ThemedButton() {
  const theme = React.useContext(ThemeContext);
  return <button>Theme is {theme}</button>;
}

2. Redux Toolkit → Slices, createAsyncThunk, Store Configuration

Definition:
Redux Toolkit (RTK) is the official, recommended way to use Redux. It reduces boilerplate and makes managing global state easier.

Key Features:
Slices → Group state + reducers.
createAsyncThunk → Handles async operations like API calls.
Configure Store → Sets up Redux store in one line.


Example:
import { configureStore, createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";

// Async API call
export const fetchUser = createAsyncThunk("user/fetch", async () => {
  const res = await fetch("/api/user");
  return res.json();
});

const userSlice = createSlice({
  name: "user",
  initialState: { data: null, status: "idle" },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.status = "loading"; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.data = action.payload;
      });
  }
});

const store = configureStore({ reducer: { user: userSlice.reducer } });

function App() {
  const dispatch = useDispatch();
  const user = useSelector((state) => state.user.data);

  return (
    <div>
      <button onClick={() => dispatch(fetchUser())}>Load User</button>
      {user && <h2>{user.name}</h2>}
    </div>
  );
}

export default function Root() {
  return <Provider store={store}><App /></Provider>;
}

👉 Redux Toolkit is best when you need structured global state and complex async logic.

3. React Query (TanStack Query) → Server State, Caching, Mutations
Definition:
React Query (now called TanStack Query) is not for UI state but for server state. It handles fetching, caching, and syncing with APIs.

Why it’s useful:
Auto-caches API responses
Refetches stale data
Manages loading and error states

Example:
import { useQuery, useMutation, QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function UserList() {
  const { data, isLoading, error } = useQuery(["users"], () =>
    fetch("/api/users").then(res => res.json())
  );

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error loading users</p>;

  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserList />
    </QueryClientProvider>
  );
}

👉 Best for remote server state, not UI-only state.

4. Zustand / Jotai / Recoil → Lightweight Alternatives to Redux
Zustand:
Simple store with hooks.
No boilerplate.
Example (Zustand):

import create from "zustand";

const useStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 }))
}));

function Counter() {
  const { count, increase } = useStore();
  return <button onClick={increase}>Count: {count}</button>;
}

Jotai:
Uses atoms (like Recoil).
Minimal and hooks-friendly.

Recoil:
Provides shared atoms/selectors.
Works well for deeply nested state.

👉 These are good when you want lightweight global state without Redux’s complexity.

5. MobX → Observable State and Computed Values
Definition:
MobX is a reactive state management library where state becomes observable and components react automatically to changes.
Example:
import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";

class CounterStore {
  count = 0;
  constructor() {
    makeAutoObservable(this);
  }
  increase() {
    this.count++;
  }
}

const store = new CounterStore();

const Counter = observer(() => (
  <button onClick={() => store.increase()}>
    Count: {store.count}
  </button>
));

👉 MobX shines when you want reactive, computed values and minimal boilerplate.

6. Context + Reducer Pattern → Minimal Global State Without Extra Libraries

Definition:
Instead of Redux, we can combine React Context with useReducer to build a small global store.

Example:

const CounterContext = React.createContext();

function counterReducer(state, action) {
  switch (action.type) {
    case "increment": return { count: state.count + 1 };
    default: return state;
  }
}

function CounterProvider({ children }) {
  const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });
  return (
    <CounterContext.Provider value={{ state, dispatch }}>
      {children}
    </CounterContext.Provider>
  );
}

function Counter() {
  const { state, dispatch } = React.useContext(CounterContext);
  return (
    <button onClick={() => dispatch({ type: "increment" })}>
      Count: {state.count}
    </button>
  );
}

function App() {
  return (
    <CounterProvider>
      <Counter />
    </CounterProvider>
  );
}

👉 This approach is great for small apps that need a bit of global state without pulling in a big library.


🎯 Final Thoughts

Lift State Up → Use for small apps, avoid unnecessary global stores.
Redux Toolkit → Great for structured, large-scale state management.
React Query → Perfect for API/server state, caching, and syncing.
Zustand / Jotai / Recoil → Lightweight
alternatives, less boilerplate.
MobX → Reactive, observable-based state management.
Context + Reducer → Minimal global state for small apps.

🚀 Choosing the right tool depends on your app’s complexity and scale. For small apps, stick with Context + Reducer or Zustand. For enterprise-level apps, go with Redux Toolkit + React Query.



Comments

Popular posts from this blog

🌟 Dot net Microservices interview questions

Here is a comprehensive list of 200 .NET microservices coding questions covering all core microservices concepts and cross-cutting concerns relevant for designing, building, deploying, and maintaining .NET-based distributed systems. 🧩 A. Microservices Fundamentals (20) Build a microservice in .NET 8 that exposes a simple CRUD API. Implement communication between two microservices using REST. How would you design microservices for an e-commerce application? Create a microservice that handles user registration and login. How do you isolate domain logic in a microservice? How to apply the "Single Responsibility Principle" in microservices? Design a service registry/discovery mechanism using custom middleware. Implement a service that handles file uploads and metadata separately. Build a stateless microservice and explain its benefits. Implement health check endpoints in .NET 8. Demonstrate versioning in a microservice API. Add Swagger/OpenAPI support to your m...

⚡ Part 1: Introduction to Generics in C#

🌍 Why Do We Need Generics? Imagine you want to create a stack (like a pile of books 📚): You can push items on top You can pop items off the top If we write a stack for integers : public class IntStack { private int[] items = new int[10]; private int index = 0; public void Push(int item) => items[index++] = item; public int Pop() => items[--index]; } 👉 Problem: This only works for int . What if we want a string stack ? Or a Customer stack ? We’d have to duplicate code for every type. 😢 ✅ Solution: Generics Generics let us create type-safe reusable code without duplication. We can say: “I don’t care what type it is yet — I’ll decide later.” 1) Generic Classes Here’s a generic stack : // Generic class "Stack<T>" // The <T> is a placeholder for any type public class Stack<T> { private T[] items = new T[10]; // Array of type T private int index = 0; // Push adds an item of type T public void P...

🚪 Part 9: API Gateway for .NET 8 Microservices (Ocelot & YARP)

Once you have multiple microservices (Products, Orders, Payments…), exposing each one directly to clients gets messy: Different base URLs Duplicated auth logic No unified rate limiting / caching Hard to evolve routes or aggregate data 👉 Enter the API Gateway — your single front door for all microservices. An API Gateway handles: ✅ Routing & path rewriting ✅ Load balancing, retries, circuit breakers ✅ Authentication & Authorization (JWT, OAuth2) ✅ Rate limiting & caching ✅ Aggregation (compose results from multiple services) In this post we’ll implement two strong options: Ocelot → config-driven, mature, DevOps-friendly YARP (Yet Another Reverse Proxy) → Microsoft’s code-first, extensible gateway ⚖️ Ocelot vs YARP — When to Choose Ocelot → JSON config, minimal C#, built-in QoS (rate limit, circuit breaker). Perfect for teams that like DevOps config-as-code. YARP → full C# control, middleware-friendly, can embed into broader apps (e.g. add dashb...