React

Redux & State Management

14 Questions

Redux is built around three core principles: a single store holds all application state, state is read-only and changed only by dispatching actions, and reducers are pure functions that compute the next state from the current state and action. This creates a unidirectional data flow where state changes are always predictable and traceable. Every state change has a corresponding action object describing what happened, making the full history inspectable and replayable with Redux DevTools.
// Action
{ type: 'counter/increment', payload: 1 }

// Reducer
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'counter/increment': return state + action.payload;
    default: return state;
  }
}
This unidirectional data flow makes state changes predictable and easy to debug.

Why it matters: Centralizing state and making every change go through a reducer means you can trace exactly what happened and when — invaluable for debugging.

Real applications: Shopping carts, multi-step forms, authentication state — any state accessed or updated by many unrelated components across the app.

Common mistakes: Putting local UI state (like whether a dropdown is open) in Redux — keep component-specific state local; Redux is for shared, global state.

The three building blocks work together: the store is the single source of truth holding all state; actions are plain objects describing what happened (with a type and optional payload); and reducers are pure functions that take the current state and an action and return the next state. Components call dispatch(action), the store passes it to the reducer, and subscribed components re-render with updated values. This unidirectional flow makes every state transition explicit and traceable.
import { createStore } from 'redux';

const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT': return { count: state.count + 1 };
    case 'DECREMENT': return { count: state.count - 1 };
    default: return state;
  }
};

const store = createStore(reducer);
store.dispatch({ type: 'INCREMENT' });
console.log(store.getState()); // { count: 1 }
Components dispatch actions to the store, the reducer computes new state, and subscribed components re-render with the updated values.

Why it matters: The clear separation of concerns — actions describe intent, reducers handle logic, store holds state — makes Redux apps easier to test and reason about.

Real applications: A "Add to Cart" button dispatches an action; the cart reducer adds the item; the cart icon re-renders with the updated count — all predictably.

Common mistakes: Putting business logic in action creators instead of reducers — reducers should contain all state transition logic.

useSelector subscribes a component to a specific slice of the Redux store — it runs your selector on every store change and re-renders the component only if the returned value changed. useDispatch returns the store's dispatch function, which you call to send actions that update state. These two hooks replaced the older connect() HOC pattern, making component code much simpler. Always select the minimum needed slice of state to avoid unnecessary re-renders.
import { useSelector, useDispatch } from 'react-redux';

function Counter() {
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </div>
  );
}
useSelector subscribes to the store and triggers re-renders only when the selected value changes.

Why it matters: These hooks replace the older connect() HOC pattern — components become simpler and easier to read.

Real applications: A header component uses useSelector to read the username; a logout button uses useDispatch to dispatch a logout action.

Common mistakes: Selecting the entire state object with useSelector(state => state) — the component re-renders on every state change; select only the specific slice you need.

createSlice takes a name, initial state, and an object of reducer functions, and automatically generates action creators and action type strings for each reducer. It uses Immer under the hood, which means you can write 'mutating' reducer code like state.value += 1 and it produces correct immutable updates automatically. This eliminates the boilerplate of separate action type constants, action creator functions, and switch-case reducers — everything is co-located in one definition.
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
    addBy: (state, action) => { state.value += action.payload; }
  }
});

export const { increment, decrement, addBy } = counterSlice.actions;
export default counterSlice.reducer;
RTK uses Immer internally, so you can write "mutating" logic in reducers while it produces immutable updates under the hood.

Why it matters: createSlice eliminates the boilerplate of writing action types, action creators, and reducers separately — everything is defined in one place.

Real applications: Any modern Redux project should use RTK — it reduces code by 50%+ compared to classic Redux while keeping the same mental model.

Common mistakes: Accidentally mutating state outside a reducer — Immer only protects mutations inside the reducer function, not in selectors or components.

configureStore is the modern, recommended way to create a Redux store. It automatically enables the Redux DevTools Extension in development, includes redux-thunk middleware by default, and checks for common mistakes like non-serializable state values. The reducer key accepts a map of slice reducers that are automatically combined. It replaces the boilerplate of createStore + applyMiddleware + composeWithDevTools with a single clean call.
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import userReducer from './userSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer
  }
});

// Wrap your app
<Provider store={store}>
  <App />
</Provider>
configureStore automatically adds thunk middleware and enables Redux DevTools — no extra setup needed.

Why it matters: Classic Redux required manual store setup with multiple packages and middleware configuration — configureStore handles all of that in one call.

Real applications: Every RTK app starts with configureStore; add slice reducers as you build features, the store grows with the app.

Common mistakes: Forgetting to wrap the app in <Provider store={store}> — without the provider, useSelector and useDispatch will throw errors.

createAsyncThunk is the standard RTK way to handle async operations like API calls in Redux. It takes an action type prefix and an async payload creator function, and automatically dispatches pending, fulfilled, and rejected actions as the operation progresses. Your slice handles these lifecycle actions in extraReducers to update loading, data, and error state. This keeps async logic co-located with the state it manages and eliminates manual thunk boilerplate.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

export const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
  const res = await fetch(`/api/users/${userId}`);
  return res.json();
});

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.data = action.payload; state.loading = false;
      });
  }
});
Dispatch it like any action: dispatch(fetchUser(123)).

Why it matters: createAsyncThunk generates the pending/fulfilled/rejected action types automatically and keeps async logic out of components.

Real applications: Fetching user profiles, submitting forms, uploading files — any async operation that should update Redux state on completion.

Common mistakes: Not handling the rejected case in the slice's extraReducers — failed API calls silently leave the UI in a loading state forever.

Redux middleware sits between dispatch and the reducer, intercepting every action before it reaches the store. It can log actions, handle async logic, transform actions, or conditionally block dispatches. Middleware follows a curried function signature: store => next => action => .... Redux Toolkit includes redux-thunk by default; add custom middleware like loggers, crash reporters, or analytics via the middleware option in configureStore.
const loggerMiddleware = (store) => (next) => (action) => {
  console.log('Dispatching:', action.type);
  const result = next(action);
  console.log('Next state:', store.getState());
  return result;
};

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) => getDefault().concat(loggerMiddleware)
});
Redux Toolkit includes redux-thunk by default. Additional middleware like redux-saga or custom loggers can be added as needed.

Why it matters: Middleware is the extension point for Redux — it's how you add logging, crash reporting, async handling, and analytics without touching reducers.

Real applications: A logging middleware records every dispatched action to the console in development; a crash reporter middleware sends action history on uncaught errors.

Common mistakes: Doing async work directly in reducers — reducers must be pure and synchronous; async logic belongs in middleware (thunks, sagas).

Redux DevTools is a browser extension that provides a full history of every dispatched action and resulting state change, enabling time-travel debugging where you step backward and forward through your app's state history. With configureStore, DevTools integration is automatic in development. Key features include: action diff view, state jump-to, action replay, dispatching test actions from the panel, and importing/exporting state for bug reproduction.
// With configureStore — DevTools enabled automatically
const store = configureStore({ reducer: rootReducer });

// Manual setup (legacy createStore)
import { composeWithDevTools } from 'redux-devtools-extension';
const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(thunk))
);
DevTools features include action replay, state diff view, action filtering, and the ability to dispatch test actions directly from the panel.

Why it matters: Time-travel debugging lets you replay actions and inspect the exact state at any point — incredibly fast for tracking down state bugs.

Real applications: When a bug is reported, replay the action sequence from logs to reproduce it exactly in the DevTools panel without needing user repro steps.

Common mistakes: Using DevTools in production — it exposes your full state history; always disable it in production builds.

RTK Query is a powerful data fetching and caching solution built directly into Redux Toolkit. You define API endpoints once using createApi, and RTK Query automatically generates React hooks, manages caching, deduplicates requests, and handles loading/error states. It replaces the pattern of createAsyncThunk + manual loading/data/error state with a declarative hook-based API. Cache tag invalidation ensures related queries refetch automatically after mutations.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query({ query: (id) => `/users/${id}` }),
    addUser: builder.mutation({ query: (body) => ({
      url: '/users', method: 'POST', body
    })})
  })
});

export const { useGetUserQuery, useAddUserMutation } = api;
RTK Query handles caching, invalidation, polling, and loading states automatically — eliminating most hand-written data fetching code.

Why it matters: RTK Query replaces custom hooks, loading states, error states, and caching logic — what used to take 100 lines takes 10.

Real applications: Replace a custom useEffect + useState data fetching pattern with useGetUsersQuery() — caching, loading, and error states included for free.

Common mistakes: Not using cache tag invalidation — after a mutation, stale data stays in the cache; use invalidatesTags to refetch affected queries automatically.

Redux is best for large apps where many components share complex state and where predictable updates, DevTools, and middleware are critical. For smaller apps, React's built-in useState/useReducer and Context are often sufficient. Lighter alternatives like Zustand or Jotai offer global state with a fraction of the boilerplate. Don't adopt Redux by default — evaluate actual complexity first and reach for it when simpler tools genuinely fall short.
// Use Redux when you need:
// - Centralized state accessed by many components
// - Complex state update logic with many reducers
// - Middleware for side effects (logging, analytics)
// - Time-travel debugging with DevTools
// - Server state caching (RTK Query)

// Consider alternatives:
// - useState/useReducer for local component state
// - Context API for simple shared state (theme, auth)
// - Zustand or Jotai for lighter global state
// - React Query/TanStack Query for server state
Don't adopt Redux prematurely. Start with React's built-in state and add Redux only when your state management needs outgrow simpler solutions.

Why it matters: Redux adds complexity and boilerplate — using it when simpler tools work means unnecessary overhead for the team.

Real applications: A small to medium app works fine with Context + useReducer; add Redux when you need DevTools, middleware, or many components sharing complex state.

Common mistakes: Defaulting to Redux for every project — evaluate the actual complexity before reaching for it; Zustand or Context may be sufficient.

Normalized state stores entities in flat, ID-indexed objects (like a database table), so each entity exists exactly once regardless of how many places reference it. When you update a user, every component referencing that user by ID picks up the change automatically — no duplication to keep in sync. RTK's createEntityAdapter provides built-in CRUD operations (addOne, updateOne, removeOne) and generated selectors (selectAll, selectById) for normalized collections.
// ❌ Unnormalized — updating a user means finding it everywhere
{ posts: [{ id: 1, author: { id: 10, name: 'Alice' }, ... }] }

// ✅ Normalized — each entity stored once, referenced by ID
{
  users: { 10: { id: 10, name: 'Alice' } },
  posts: { 1: { id: 1, authorId: 10, title: 'Hello' } }
}

// RTK's createEntityAdapter helps manage normalized state
const usersAdapter = createEntityAdapter();
const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState(),
  reducers: {
    addUser: usersAdapter.addOne,
    updateUser: usersAdapter.updateOne
  }
});
RTK's createEntityAdapter provides built-in CRUD operations and selectors for normalized collections.

Why it matters: Normalized state eliminates data duplication — update a user once and every component that references that user sees the update.

Real applications: A social app stores posts and users in separate normalized collections; a post showing an author's name always reads the latest user data.

Common mistakes: Storing nested objects instead of IDs — deeply nested state is hard to update and causes missed re-renders when deep properties change.

A selector is a function that extracts and optionally derives data from the Redux store, abstracting the state shape from components. Memoized selectors with createSelector only recompute derived data when their input selectors change — preventing expensive calculations (like filtering 10,000 items) from running on every unrelated state update. Always define selectors outside the component as stable function references so useSelector's reference equality check works correctly.
// Simple selector
const selectCount = (state) => state.counter.value;

// Derived selector — compute from raw state
const selectCompletedTodos = (state) =>
  state.todos.filter(todo => todo.completed);

// Memoized selector with createSelector (reselect)
import { createSelector } from '@reduxjs/toolkit';

const selectVisibleTodos = createSelector(
  [(state) => state.todos, (state) => state.filter],
  (todos, filter) => todos.filter(t => matchesFilter(t, filter))
);
createSelector only recalculates when its inputs change, preventing expensive recomputations on unrelated state updates.

Why it matters: Memoized selectors prevent derived data from being recalculated on every render — crucial performance for filtering or sorting large lists.

Real applications: A selector that filters 1000 items by category only reruns when the items list or the active category changes, not on every unrelated dispatch.

Common mistakes: Creating new inline selector functions inside useSelector — these create new function references every render, defeating memoization.

Zustand is a minimal state management library where you define state and update functions together in a single create() call — no actions, reducers, or boilerplate. Updates are made by calling setter functions directly on the store, handled immutably behind the scenes. It's a great choice for small to medium apps where Redux's structure feels like overkill. For large enterprise apps with many developers, Redux Toolkit's conventions, DevTools, and RTK Query are often worth the extra structure.
import { create } from 'zustand';

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

// Usage in a component
function Counter() {
  const { count, increment } = useStore();
  return <button onClick={increment}>{count}</button>;
}
Zustand is great for smaller to medium apps. Redux Toolkit is better for large teams that need strict conventions, DevTools, and middleware like RTK Query.

Why it matters: Knowing the tradeoffs helps you pick the right tool — Zustand's simplicity vs Redux's structure and ecosystem.

Real applications: Zustand for a startup's MVP where speed matters; Redux Toolkit for an enterprise app with a large team where conventions and DevTools are critical.

Common mistakes: Mixing Zustand and Redux in the same app — pick one global state solution; mixing them causes confusion about where state lives.

Optimistic updates immediately apply the expected UI change before the API call completes, giving users instant feedback without waiting for a server round-trip. When the server succeeds, the state is already correct; when it fails, the rejection case reverts the change. This technique is essential for frequent user actions like likes, checkmarks, and drags, where any latency would feel unnatural. Always implement the rejection rollback handler or users see stale state after network errors.
const todoSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    toggleTodo: (state, action) => {
      const todo = state.find(t => t.id === action.payload);
      if (todo) todo.completed = !todo.completed; // instant UI update
    }
  },
  extraReducers: (builder) => {
    builder.addCase(saveTodoToggle.rejected, (state, action) => {
      // Revert optimistic update on API failure
      const todo = state.find(t => t.id === action.meta.arg);
      if (todo) todo.completed = !todo.completed;
    });
  }
});
Always implement the rejection case to revert the change if the server call fails — otherwise the UI will be out of sync with the database.

Why it matters: Optimistic updates make the app feel instant — users see their change immediately instead of waiting for the server to confirm it.

Real applications: Liking a post, checking off a task, or marking an email as read — update the UI instantly and roll back silently if the server rejects it.

Common mistakes: Not implementing the rejection rollback — if the network fails, the UI shows a change that never persisted, confusing users.