Redux & Redux Toolkit (RTK) β Complete Interview Guide
Covers: Plain Redux from scratch β Redux Toolkit β RTK Query β Real-world patterns
Table of Contents
- Why Redux?
- Plain Redux β No RTK
- Redux Middleware β Thunk from scratch
- Redux Toolkit (RTK) β Modern Redux
- createSlice in depth
- createAsyncThunk in depth
- createEntityAdapter
- createSelector (Reselect)
- RTK Query β Complete Guide
- RTK Query Advanced β Tags, Pagination, Optimistic Updates
- Project Folder Structure
- TypeScript with RTK
- Testing Redux
- Interview Q&A β Redux & RTK
1. Why Redux?
The Problem Redux Solves
Without Redux (prop drilling):
App
βββ Dashboard
βββ Sidebar
βββ UserAvatar β needs user data from top
With Redux:
Store (single source of truth)
βββ UserAvatar β reads directly from store
βββ Header β reads directly from store
βββ Settings β reads + writes directly
Three Core Principles
| Principle | Meaning |
|---|---|
| Single source of truth | The whole app state lives in one store |
| State is read-only | You can only change state by dispatching actions |
| Changes via pure functions | Reducers are pure β same input β same output, no side effects |
When to Use Redux
- Shared state accessed by many components in different parts of the tree
- Complex state logic with many transitions
- Server state with caching (RTK Query)
- Need for time-travel debugging / reproducible state
When NOT to Use Redux
- Local UI state (open/close modal β
useState) - Server state only (use RTK Query / React Query instead)
- Small apps with simple state
2. Plain Redux β No RTK
Understanding plain Redux helps you understand what RTK is solving.
Install
npm install redux react-redux
Step 1 β Define Action Types
// store/actionTypes.js
export const INCREMENT = "counter/INCREMENT";
export const DECREMENT = "counter/DECREMENT";
export const ADD_TODO = "todos/ADD_TODO";
export const TOGGLE_TODO = "todos/TOGGLE_TODO";
export const DELETE_TODO = "todos/DELETE_TODO";
Step 2 β Define Action Creators
// store/actions.js
import {
INCREMENT,
DECREMENT,
ADD_TODO,
TOGGLE_TODO,
DELETE_TODO,
} from "./actionTypes";
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { id: Date.now(), text, completed: false },
});
export const toggleTodo = (id) => ({
type: TOGGLE_TODO,
payload: id,
});
export const deleteTodo = (id) => ({
type: DELETE_TODO,
payload: id,
});
Step 3 β Write Reducers
// store/counterReducer.js
import { INCREMENT, DECREMENT } from "./actionTypes";
const initialState = { count: 0 };
export function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 }; // MUST return new object!
case DECREMENT:
return { ...state, count: state.count - 1 };
default:
return state; // Always return state for unknown actions
}
}
// store/todosReducer.js
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO } from "./actionTypes";
const initialState = [];
export function todosReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [...state, action.payload]; // new array, don't push!
case TOGGLE_TODO:
return state.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed } // new object!
: todo
);
case DELETE_TODO:
return state.filter((todo) => todo.id !== action.payload);
default:
return state;
}
}
Step 4 β Combine Reducers & Create Store
// store/index.js
import { createStore, combineReducers } from "redux";
import { counterReducer } from "./counterReducer";
import { todosReducer } from "./todosReducer";
const rootReducer = combineReducers({
counter: counterReducer,
todos: todosReducer,
});
export const store = createStore(
rootReducer,
window.__REDUX_DEVTOOLS_EXTENSION__?.() // enable Redux DevTools
);
Step 5 β Provide Store to React
// main.jsx / index.jsx
import { Provider } from "react-redux";
import { store } from "./store";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);
Step 6 β Use in Components
// Counter.jsx
import { useSelector, useDispatch } from "react-redux";
import { increment, decrement } from "./store/actions";
export function Counter() {
const count = useSelector((state) => state.counter.count);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
// TodoList.jsx
import { useSelector, useDispatch } from "react-redux";
import { addTodo, toggleTodo, deleteTodo } from "./store/actions";
import { useState } from "react";
export function TodoList() {
const todos = useSelector((state) => state.todos);
const dispatch = useDispatch();
const [text, setText] = useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button
onClick={() => {
dispatch(addTodo(text));
setText("");
}}>
Add
</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
onClick={() => dispatch(toggleTodo(todo.id))}>
{todo.text}
</span>
<button onClick={() => dispatch(deleteTodo(todo.id))}>β</button>
</li>
))}
</ul>
</div>
);
}
The Boilerplate Problem (Why RTK Exists)
Plain Redux requires you to:
- Manually define action type constants
- Manually write action creators
- Write
switchstatements in reducers - Manually spread state (
...state) to avoid mutation - Set up middleware manually
- Configure DevTools manually
RTK eliminates all of this.
3. Redux Middleware β Thunk from Scratch
What is Middleware?
Middleware sits between dispatch and the reducer. It can intercept, delay, or transform actions.
dispatch(action)
β middleware1
β middleware2
β reducer
Writing Thunk Middleware from Scratch
// Without redux-thunk, dispatch only accepts plain objects.
// Thunk allows dispatching functions (for async operations).
const thunkMiddleware = (store) => (next) => (action) => {
if (typeof action === "function") {
// It's a thunk! Call it with dispatch and getState
return action(store.dispatch, store.getState);
}
// Plain action β pass to next middleware / reducer
return next(action);
};
Applying Middleware
import { createStore, applyMiddleware } from "redux";
const store = createStore(rootReducer, applyMiddleware(thunkMiddleware));
Using Thunk (Plain Redux)
// Async action creator returns a function, not an object
export const fetchUser = (userId) => async (dispatch, getState) => {
dispatch({ type: "user/fetchPending" });
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
dispatch({ type: "user/fetchFulfilled", payload: data });
} catch (err) {
dispatch({ type: "user/fetchRejected", payload: err.message });
}
};
// In component:
dispatch(fetchUser("123"));
Logger Middleware Example
const loggerMiddleware = (store) => (next) => (action) => {
console.group(action.type);
console.log("prev state", store.getState());
console.log("action", action);
const result = next(action);
console.log("next state", store.getState());
console.groupEnd();
return result;
};
4. Redux Toolkit (RTK) β Modern Redux
Install
npm install @reduxjs/toolkit react-redux
What RTK Provides
| RTK API | Replaces |
|---|---|
configureStore |
createStore + applyMiddleware + DevTools setup |
createSlice |
action types + action creators + reducer |
createAsyncThunk |
manual thunk functions |
createEntityAdapter |
normalized state helpers |
createSelector |
memoized selectors (Reselect) |
createApi (RTK Query) |
all data fetching + caching logic |
configureStore
// store/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
import todosReducer from "./todosSlice";
import userReducer from "./userSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer,
user: userReducer,
},
// middleware is pre-configured with redux-thunk + serializability check
// devtools enabled in development automatically
});
// TypeScript: infer types from store
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
What configureStore does automatically:
- Adds
redux-thunkmiddleware - Adds Redux DevTools Extension support
- Adds development-only checks (serializable state, immutability)
5. createSlice in depth
A slice is a self-contained unit of Redux state β it combines the reducer + action creators in one place.
Full Example β Counter Slice
// store/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter", // used as prefix for action types
initialState: {
value: 0,
step: 1,
history: [],
},
reducers: {
// Simple reducer
increment(state) {
state.value += state.step; // Immer allows direct mutation!
state.history.push(state.value);
},
decrement(state) {
state.value -= state.step;
state.history.push(state.value);
},
// Reducer with payload
incrementByAmount(state, action) {
state.value += action.payload;
},
// Reducer with prepare callback (customize action)
addWithMeta: {
reducer(state, action) {
state.value += action.payload.amount;
},
prepare(amount) {
return {
payload: {
amount,
timestamp: new Date().toISOString(),
id: Math.random(),
},
};
},
},
setStep(state, action) {
state.step = action.payload;
},
reset() {
// Returning a new state (instead of mutating) is also valid
return { value: 0, step: 1, history: [] };
},
},
});
// Actions are auto-generated
export const {
increment,
decrement,
incrementByAmount,
setStep,
reset,
addWithMeta,
} = counterSlice.actions;
// Action types (for reference or testing)
console.log(increment.type); // "counter/increment"
console.log(incrementByAmount.type); // "counter/incrementByAmount"
// Reducer
export default counterSlice.reducer;
Full Example β Todos Slice
// store/todosSlice.js
import { createSlice, nanoid } from "@reduxjs/toolkit";
const todosSlice = createSlice({
name: "todos",
initialState: {
items: [],
filter: "all", // "all" | "active" | "completed"
},
reducers: {
addTodo: {
reducer(state, action) {
state.items.push(action.payload);
},
prepare(text) {
return {
payload: {
id: nanoid(), // RTK provides nanoid!
text,
completed: false,
createdAt: Date.now(),
},
};
},
},
toggleTodo(state, action) {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed; // direct mutation via Immer
}
},
deleteTodo(state, action) {
state.items = state.items.filter((t) => t.id !== action.payload);
},
editTodo(state, action) {
const { id, text } = action.payload;
const todo = state.items.find((t) => t.id === id);
if (todo) todo.text = text;
},
setFilter(state, action) {
state.filter = action.payload;
},
clearCompleted(state) {
state.items = state.items.filter((t) => !t.completed);
},
},
});
export const {
addTodo,
toggleTodo,
deleteTodo,
editTodo,
setFilter,
clearCompleted,
} = todosSlice.actions;
export default todosSlice.reducer;
Selectors β Colocate with Slice
// Add to bottom of todosSlice.js
export const selectAllTodos = (state) => state.todos.items;
export const selectFilter = (state) => state.todos.filter;
export const selectActiveTodos = (state) =>
state.todos.items.filter((t) => !t.completed);
export const selectCompletedTodos = (state) =>
state.todos.items.filter((t) => t.completed);
export const selectTodoById = (id) => (state) =>
state.todos.items.find((t) => t.id === id);
Immer β How It Works Inside createSlice
// What you write (looks like mutation):
increment(state) {
state.value += 1;
}
// What Immer does behind the scenes:
// 1. Creates a draft proxy of state
// 2. Records your mutations on the draft
// 3. Produces a new immutable object from those mutations
// 4. Returns the new immutable object β original state untouched!
// Rules:
// β
You can mutate the draft state
// β
OR return a new value β but NOT both
// β Don't return undefined accidentally
reset(state) {
return { value: 0 }; // β
returning new value
}
// β This is a bug:
badReset(state) {
state = { value: 0 }; // β reassigning local variable - doesn't work!
// You need to return it or mutate in-place
}
6. createAsyncThunk in depth
Basic Usage
// store/userSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
// 1. Create the async thunk
export const fetchUserById = createAsyncThunk(
"users/fetchById", // action type prefix
async (userId, thunkAPI) => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data; // becomes action.payload in fulfilled
}
);
// 2. Handle lifecycle actions in createSlice
const usersSlice = createSlice({
name: "users",
initialState: {
entities: {},
loading: "idle", // "idle" | "pending" | "succeeded" | "failed"
error: null,
currentRequestId: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state, action) => {
if (state.loading === "idle") {
state.loading = "pending";
state.currentRequestId = action.meta.requestId;
}
})
.addCase(fetchUserById.fulfilled, (state, action) => {
const { requestId } = action.meta;
if (
state.loading === "pending" &&
state.currentRequestId === requestId
) {
state.loading = "idle";
state.entities[action.payload.id] = action.payload;
}
})
.addCase(fetchUserById.rejected, (state, action) => {
if (state.loading === "pending") {
state.loading = "idle";
state.error = action.error.message;
}
});
},
});
export default usersSlice.reducer;
thunkAPI β Full Power
export const fetchCartWithAuth = createAsyncThunk(
"cart/fetchWithAuth",
async (_, thunkAPI) => {
const {
dispatch, // dispatch other actions
getState, // read current state
rejectWithValue, // return a known error payload
fulfillWithValue, // return a success payload with meta
signal, // AbortController signal for cancellation
extra, // injected extra argument (e.g. API service)
} = thunkAPI;
// Access other slice state
const token = getState().auth.token;
if (!token) {
return thunkAPI.rejectWithValue("Not authenticated");
}
try {
const response = await fetch("/api/cart", {
headers: { Authorization: `Bearer ${token}` },
signal, // supports abort!
});
if (!response.ok) {
const error = await response.json();
return thunkAPI.rejectWithValue(error.message);
}
return await response.json();
} catch (err) {
if (err.name === "AbortError") {
return thunkAPI.rejectWithValue("Request cancelled");
}
throw err;
}
}
);
Handling rejectWithValue
extraReducers: (builder) => {
builder.addCase(fetchCartWithAuth.rejected, (state, action) => {
// action.error.message β for thrown errors
// action.payload β for rejectWithValue (your custom message)
state.error = action.payload ?? action.error.message;
});
};
Cancelling a Request
function SearchResults({ query }) {
const dispatch = useDispatch();
useEffect(() => {
const promise = dispatch(searchProducts(query));
return () => {
promise.abort(); // createAsyncThunk supports .abort() on the returned promise
};
}, [query]);
}
Dispatching Other Actions Inside Thunk
export const loginAndFetchProfile = createAsyncThunk(
"auth/loginAndFetch",
async (credentials, { dispatch }) => {
const { token } = await login(credentials);
// Dispatch another slice action
dispatch(setToken(token));
// Dispatch another thunk
await dispatch(fetchUserProfile());
return token;
}
);
Error Handling Pattern
// In component
async function handleLogin(credentials) {
const result = await dispatch(loginUser(credentials));
if (loginUser.fulfilled.match(result)) {
navigate("/dashboard");
} else if (loginUser.rejected.match(result)) {
setError(result.payload || "Login failed");
}
}
7. createEntityAdapter
Manages normalized state (like a lookup table) with automatic CRUD helpers.
What is Normalized State?
// Denormalized (bad for lookups)
items: [
{ id: 1, name: "Apple" },
{ id: 2, name: "Banana" }
]
// Normalized (fast lookups by id)
ids: [1, 2],
entities: {
1: { id: 1, name: "Apple" },
2: { id: 2, name: "Banana" }
}
Full Example
import {
createSlice,
createEntityAdapter,
createAsyncThunk,
} from "@reduxjs/toolkit";
// 1. Create adapter
const productsAdapter = createEntityAdapter({
// Optional: custom id selector (default: item.id)
selectId: (product) => product.productId,
// Optional: sort order
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
// 2. Initial state includes ids[], entities{}
const initialState = productsAdapter.getInitialState({
loading: false,
error: null,
// extra fields beyond ids/entities
totalCount: 0,
});
export const fetchProducts = createAsyncThunk("products/fetchAll", async () => {
const res = await fetch("/api/products");
return res.json();
});
const productsSlice = createSlice({
name: "products",
initialState,
reducers: {
// Adapter gives you these methods:
addOne: productsAdapter.addOne,
addMany: productsAdapter.addMany,
updateOne: productsAdapter.updateOne,
updateMany: productsAdapter.updateMany,
upsertOne: productsAdapter.upsertOne, // add or update
upsertMany: productsAdapter.upsertMany,
removeOne: productsAdapter.removeOne,
removeMany: productsAdapter.removeMany,
setAll: productsAdapter.setAll, // replace entire list
// Custom reducer using adapter methods
updatePrice(state, action) {
const { id, price } = action.payload;
productsAdapter.updateOne(state, { id, changes: { price } });
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
productsAdapter.setAll(state, action.payload.items);
state.totalCount = action.payload.total;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
export const { addOne, removeOne, updatePrice } = productsSlice.actions;
export default productsSlice.reducer;
// 3. Generate selectors
export const {
selectAll: selectAllProducts,
selectById: selectProductById,
selectIds: selectProductIds,
selectEntities: selectProductEntities,
selectTotal: selectProductCount,
} = productsAdapter.getSelectors((state) => state.products);
Usage in Component
function ProductList() {
const products = useSelector(selectAllProducts);
const product = useSelector((state) => selectProductById(state, "abc123"));
const total = useSelector(selectProductCount);
return (
<div>
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
8. createSelector (Reselect)
Memoized selectors β recompute only when inputs change.
The Problem Without Memoization
// This creates a new array every render β child re-renders even if data is same!
const activeTodos = useSelector((state) =>
state.todos.items.filter((t) => !t.completed)
);
Basic createSelector
import { createSelector } from "@reduxjs/toolkit";
// Input selectors (cheap β just read from state)
const selectTodoItems = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;
// Output selector (expensive β runs only when inputs change)
export const selectFilteredTodos = createSelector(
[selectTodoItems, selectFilter],
(items, filter) => {
switch (filter) {
case "active":
return items.filter((t) => !t.completed);
case "completed":
return items.filter((t) => t.completed);
default:
return items;
}
}
);
// Multiple inputs
export const selectTodoStats = createSelector([selectTodoItems], (items) => ({
total: items.length,
active: items.filter((t) => !t.completed).length,
completed: items.filter((t) => t.completed).length,
}));
Parameterized Selectors (Factory Pattern)
// Selector that takes an argument
export const makeSelectTodoById = () =>
createSelector([(state) => state.todos.items, (_, id) => id], (items, id) =>
items.find((t) => t.id === id)
);
// Usage β each component instance gets its own memoized selector
function TodoItem({ id }) {
const selectTodo = useMemo(makeSelectTodoById, []);
const todo = useSelector((state) => selectTodo(state, id));
return <li>{todo?.text}</li>;
}
When createSelector Recalculates
state.todos.items changes β selectFilteredTodos recalculates
state.todos.filter changes β selectFilteredTodos recalculates
state.counter changes β selectFilteredTodos does NOT recalculate (different input)
Same state β Returns cached result (same array reference!)
9. RTK Query β Complete Guide
RTK Query is a data-fetching and caching tool built into RTK. Think of it as React Query but integrated with Redux.
Install (already in @reduxjs/toolkit)
npm install @reduxjs/toolkit react-redux
Why RTK Query?
| Without RTK Query | With RTK Query |
|---|---|
| Manual loading/error state | Automatic isLoading, isError |
| Manual cache management | Automatic caching by endpoint + args |
| Duplicate requests | Auto-deduplication |
| Manual refetch on mutations | Auto-invalidation via tags |
| useEffect + fetch boilerplate | Single hook call |
Step 1 β Create an API Slice
// store/api/postsApi.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const postsApi = createApi({
// Key in Redux state (must be unique)
reducerPath: "postsApi",
// Base configuration for all requests
baseQuery: fetchBaseQuery({
baseUrl: "https://jsonplaceholder.typicode.com",
// Add auth headers to every request
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
},
}),
// Tag types for cache invalidation
tagTypes: ["Post", "Comment", "User"],
// Define endpoints
endpoints: (builder) => ({
// QUERY β fetches and caches data
getPosts: builder.query({
query: () => "/posts",
providesTags: ["Post"],
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: "Post", id }],
}),
getPostsByUser: builder.query({
query: (userId) => `/posts?userId=${userId}`,
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: "Post", id })),
{ type: "Post", id: "LIST" },
]
: [{ type: "Post", id: "LIST" }],
}),
// MUTATION β creates/updates/deletes data
createPost: builder.mutation({
query: (newPost) => ({
url: "/posts",
method: "POST",
body: newPost,
}),
// After create, invalidate the list cache β auto-refetch
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: "PATCH",
body: patch,
}),
// Invalidate only this specific post
invalidatesTags: (result, error, { id }) => [{ type: "Post", id }],
}),
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, id) => [
{ type: "Post", id },
{ type: "Post", id: "LIST" },
],
}),
}),
});
// Auto-generated hooks (naming: use + EndpointName + Query/Mutation)
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useGetPostsByUserQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
Step 2 β Add to Store
// store/store.js
import { configureStore } from "@reduxjs/toolkit";
import { postsApi } from "./api/postsApi";
import authReducer from "./authSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
[postsApi.reducerPath]: postsApi.reducer, // RTK Query manages its own state
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postsApi.middleware), // required for caching!
});
Step 3 β Use in Components
// PostList.jsx
import { useGetPostsQuery, useDeletePostMutation } from "../store/api/postsApi";
export function PostList() {
const {
data: posts, // the result data
isLoading, // true on first load, no cached data
isFetching, // true whenever fetching (including refetch)
isSuccess, // true if last request succeeded
isError, // true if last request failed
error, // the error object
refetch, // manually trigger a refetch
} = useGetPostsQuery();
const [deletePost, { isLoading: isDeleting }] = useDeletePostMutation();
if (isLoading) return <div>Loading posts...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>
{post.title}
<button onClick={() => deletePost(post.id)} disabled={isDeleting}>
Delete
</button>
</li>
))}
</ul>
);
}
// CreatePostForm.jsx
import { useState } from "react";
import { useCreatePostMutation } from "../store/api/postsApi";
export function CreatePostForm() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [createPost, { isLoading, isSuccess, isError, error, reset }] =
useCreatePostMutation();
async function handleSubmit(e) {
e.preventDefault();
await createPost({ title, body, userId: 1 });
setTitle("");
setBody("");
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Body"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Create Post"}
</button>
{isSuccess && <p>Post created!</p>}
{isError && <p>Error: {error.message}</p>}
</form>
);
}
// PostDetail.jsx
import { useGetPostByIdQuery } from "../store/api/postsApi";
export function PostDetail({ postId }) {
const { data: post, isLoading } = useGetPostByIdQuery(postId, {
// Options:
skip: !postId, // don't fetch if postId is null
pollingInterval: 30_000, // refetch every 30 seconds
refetchOnMountOrArgChange: true, // always refetch on mount
refetchOnFocus: true, // refetch when window regains focus
refetchOnReconnect: true, // refetch on network reconnect
});
if (isLoading) return <Spinner />;
return (
<article>
<h1>{post?.title}</h1>
<p>{post?.body}</p>
</article>
);
}
10. RTK Query Advanced
Cache Behavior & Tags Explained
Tags are the key to automatic refetching.
providesTags β "this query provides data tagged as X"
invalidatesTags β "this mutation invalidates all queries tagged X β auto-refetch"
// Detailed tag patterns
// Pattern 1: List + individual items
getPosts: builder.query({
query: () => "/posts",
providesTags: (result) =>
result
? [
{ type: "Post", id: "LIST" }, // for the list
...result.map(({ id }) => ({ type: "Post", id })), // for each item
]
: [{ type: "Post", id: "LIST" }],
}),
// When deleting, invalidate both list and the specific post:
deletePost: builder.mutation({
invalidatesTags: (result, error, id) => [
{ type: "Post", id },
{ type: "Post", id: "LIST" },
],
}),
// When creating, only invalidate list (new post doesn't have an id yet):
createPost: builder.mutation({
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
transformResponse β Shape the Data
getPosts: builder.query({
query: () => "/posts",
// Transform before caching
transformResponse: (response) => {
return response.map((post) => ({
...post,
titleUpperCase: post.title.toUpperCase(),
}));
},
}),
// Paginated response shape
getPagedPosts: builder.query({
query: ({ page, limit }) => `/posts?_page=${page}&_limit=${limit}`,
transformResponse: (response, meta) => {
const total = parseInt(meta.response.headers.get("X-Total-Count"), 10);
return { posts: response, total, pages: Math.ceil(total / 10) };
},
}),
Pagination
// In endpoints
getPostsPaged: builder.query({
query: ({ page = 1, limit = 10 }) => `/posts?_page=${page}&_limit=${limit}`,
providesTags: (result, error, { page }) => [{ type: "Post", id: `PAGE_${page}` }],
}),
// Paginated component
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isLoading, isFetching } = useGetPostsPagedQuery({
page,
limit: 10,
});
// Prefetch next page
const prefetchPage = usePrefetch("getPostsPaged");
return (
<div>
{isLoading ? <Spinner /> : <PostList posts={data?.posts} />}
<button
onClick={() => setPage((p) => p - 1)}
disabled={page === 1 || isFetching}
onMouseEnter={() => prefetchPage({ page: page - 1 })}>
β Prev
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={isFetching}
onMouseEnter={() => prefetchPage({ page: page + 1 })}>
Next β
</button>
</div>
);
}
Optimistic Updates
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: "PATCH",
body: patch,
}),
// Optimistic update β update cache before response
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
// 1. Optimistically update the cache
const patchResult = dispatch(
postsApi.util.updateQueryData("getPostById", id, (draft) => {
Object.assign(draft, patch); // Immer draft!
})
);
try {
await queryFulfilled; // 2. Wait for real response
} catch {
patchResult.undo(); // 3. Rollback on error
}
},
}),
Custom baseQuery β Handle Auth Refresh
import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { tokenRefreshed, loggedOut } from "../authSlice";
const baseQuery = fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) headers.set("authorization", `Bearer ${token}`);
return headers;
},
});
// Wrapper that handles 401 β refresh token β retry
export const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
// Try to refresh
const refreshResult = await baseQuery("/auth/refresh", api, extraOptions);
if (refreshResult.data) {
api.dispatch(tokenRefreshed(refreshResult.data));
// Retry original request
result = await baseQuery(args, api, extraOptions);
} else {
api.dispatch(loggedOut());
}
}
return result;
};
// Use in createApi
export const api = createApi({
baseQuery: baseQueryWithReauth,
// ...
});
Manually Updating Cache
// Force refetch
dispatch(postsApi.util.invalidateTags(["Post"]));
// Update cache manually without refetch
dispatch(
postsApi.util.updateQueryData("getPosts", undefined, (draftPosts) => {
draftPosts.push({ id: 999, title: "New post" });
})
);
// Prefetch in event handlers
const prefetchPost = usePrefetch("getPostById");
<div onMouseEnter={() => prefetchPost(post.id)}>
<PostCard post={post} />
</div>;
Polling
function LiveDashboard() {
const { data } = useGetStatsQuery(undefined, {
pollingInterval: 5000, // refetch every 5 seconds
skipPollingIfUnfocused: true, // pause when tab is not active
});
return <Stats data={data} />;
}
11. Project Folder Structure
src/
βββ app/
β βββ store.ts β configureStore + all reducers/middleware
β βββ hooks.ts β typed useAppDispatch, useAppSelector
β
βββ features/
β βββ auth/
β β βββ authSlice.ts
β β βββ LoginForm.tsx
β β βββ authSelectors.ts
β β
β βββ posts/
β β βββ postsSlice.ts
β β βββ PostList.tsx
β β βββ PostDetail.tsx
β β βββ CreatePostForm.tsx
β β
β βββ cart/
β βββ cartSlice.ts
β βββ CartSummary.tsx
β
βββ services/
βββ api.ts β RTK Query createApi (shared baseQuery)
βββ postsApi.ts β posts endpoints
βββ usersApi.ts β users endpoints
Typed Hooks (TypeScript)
// app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
12. TypeScript with RTK
Typed Slice
// features/counter/counterSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CounterState {
value: number;
status: "idle" | "loading" | "failed";
}
const initialState: CounterState = {
value: 0,
status: "idle",
};
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
Typed createAsyncThunk
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState, AppDispatch } from "../../app/store";
// Specify types: [ReturnType, ArgType, ThunkAPIConfig]
export const fetchUserById = createAsyncThunk<
User, // return type
string, // argument type (userId)
{
state: RootState;
dispatch: AppDispatch;
rejectValue: string;
}
>("users/fetchById", async (userId: string, { rejectWithValue }) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
return rejectWithValue("Failed to fetch user");
}
return (await response.json()) as User;
});
Typed RTK Query
// services/postsApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
interface PostsResponse {
posts: Post[];
total: number;
}
export const postsApi = createApi({
reducerPath: "postsApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Post"],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => "/posts",
}),
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
}),
createPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({ url: "/posts", method: "POST", body }),
}),
}),
});
13. Testing Redux
Testing Reducers (Pure Functions)
// counterSlice.test.js
import counterReducer, {
increment,
decrement,
incrementByAmount,
} from "./counterSlice";
describe("counterReducer", () => {
it("should return initial state", () => {
expect(counterReducer(undefined, { type: "unknown" })).toEqual({
value: 0,
step: 1,
history: [],
});
});
it("should increment", () => {
const state = { value: 5, step: 1, history: [] };
expect(counterReducer(state, increment())).toEqual({
value: 6,
step: 1,
history: [6],
});
});
it("should handle incrementByAmount", () => {
const state = { value: 5, step: 1, history: [] };
expect(counterReducer(state, incrementByAmount(3))).toEqual(
expect.objectContaining({ value: 8 })
);
});
});
Testing Async Thunks
import { configureStore } from "@reduxjs/toolkit";
import usersReducer, { fetchUserById } from "./usersSlice";
// Mock fetch
global.fetch = jest.fn();
describe("fetchUserById thunk", () => {
let store;
beforeEach(() => {
store = configureStore({ reducer: { users: usersReducer } });
fetch.mockClear();
});
it("dispatches fulfilled on success", async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => ({ id: "1", name: "John" }),
});
await store.dispatch(fetchUserById("1"));
const state = store.getState().users;
expect(state.entities["1"]).toEqual({ id: "1", name: "John" });
expect(state.loading).toBe("idle");
});
it("dispatches rejected on failure", async () => {
fetch.mockRejectedValue(new Error("Network error"));
await store.dispatch(fetchUserById("1"));
const state = store.getState().users;
expect(state.error).toBe("Network error");
});
});
Testing Selectors
import { selectFilteredTodos } from "./todosSlice";
describe("selectFilteredTodos", () => {
const state = {
todos: {
items: [
{ id: 1, text: "Buy milk", completed: false },
{ id: 2, text: "Read book", completed: true },
],
filter: "active",
},
};
it("filters active todos", () => {
const result = selectFilteredTodos(state);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("Buy milk");
});
});
Testing RTK Query with MSW (Mock Service Worker)
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { renderWithProviders } from "../test-utils";
import { PostList } from "./PostList";
const server = setupServer(
http.get("/api/posts", () => {
return HttpResponse.json([
{ id: 1, title: "First post" },
{ id: 2, title: "Second post" },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("renders posts", async () => {
const { findByText } = renderWithProviders(<PostList />);
expect(await findByText("First post")).toBeInTheDocument();
expect(await findByText("Second post")).toBeInTheDocument();
});
14. Interview Q&A
Core Redux Questions
Q: What is the difference between Redux and Context API?
Context API is a React primitive for passing data through the tree β it re-renders all consumers on every state change and has no built-in optimization. Redux uses selective subscriptions via useSelector + reference equality checks, so only components whose selected slice changed re-render. Redux also has DevTools, middleware, and a structured update pattern.
Q: Why must reducers be pure functions?
Pure = same inputs β same output, no side effects. Redux relies on this to: (1) detect changes using === comparison, (2) enable time-travel debugging (replay actions), (3) make state predictable and testable.
Q: What happens when you mutate state directly in a reducer?
The reference doesnβt change, so === comparison says βnothing changedβ β React-Redux wonβt trigger re-renders β UI goes stale. Always return a new object/array.
Q: What is Immer and why does RTK use it?
Immer creates a mutable draft proxy of your state. You write imperative mutations on the draft, and Immer produces a new immutable object. RTK uses Immer in createSlice so you can write state.count++ instead of { ...state, count: state.count + 1 }, reducing bugs from accidentally forgetting to spread.
Q: What is the difference between isLoading and isFetching in RTK Query?
| Β | isLoading |
isFetching |
|---|---|---|
| First load (no cache) | true |
true |
| Subsequent refetch (cache exists) | false |
true |
| Mutation in progress | false |
false |
Use isLoading to show skeleton on first load. Use isFetching to show a subtle refresh indicator.
Q: How does RTK Query cache work?
Each endpoint+args combination gets a cache entry. The cache is keyed by reducerPath + endpointName + serializedArgs. Cache entries are kept for keepUnusedDataFor seconds (default 60) after the last subscriber unmounts. Accessing the same query from multiple components shares one cache entry and one network request.
Q: Explain tag-based invalidation in RTK Query.
Tags are labels. providesTags marks what data a query caches. invalidatesTags marks what data a mutation makes stale. When a mutation runs and its invalidatesTags overlaps with any queryβs providesTags, RTK Query refetches those queries automatically. This is how you keep lists fresh after a create/delete.
Q: What is the difference between createAsyncThunk returning a value vs calling rejectWithValue?
- Thrown error β dispatches
rejectedaction,action.errorhas the error info,action.payloadis undefined. rejectWithValue(data)β dispatchesrejectedaction,action.payloadhas your custom data,action.erroris a generic βRejectedβ error. UserejectWithValueto pass structured error info to the reducer/component.
Q: How do you handle optimistic updates in RTK Query?
Use onQueryStarted in a mutation endpoint. Call postsApi.util.updateQueryData(...) to update the cache immediately (before the response). Await queryFulfilled, and if it throws, call patchResult.undo() to roll back. This gives instant UI feedback with safe rollback on network failure.
Q: How does createEntityAdapter normalize state?
It stores items as { ids: [], entities: {} } β ids is an ordered array of IDs for iteration, entities is a lookup object by ID for O(1) access. It generates CRUD methods (addOne, updateOne, removeOne, etc.) that operate on this shape, eliminating the need to write spread-and-filter boilerplate.
Q: What is createSelector and when would you use it?
createSelector from Reselect creates memoized selectors. A selector only recomputes when its input selectors return different values. Use it whenever your selector does expensive computation (filter, sort, map, aggregation) that would otherwise run on every render. Without memoization, the selector returns a new array/object reference each time β useSelector thinks state changed β component re-renders.
Q: What middleware does configureStore add by default?
redux-thunkβ lets you dispatch functions (async actions)- Serializability check (dev only) β warns if non-serializable values (functions, Promises, class instances) are in state or actions
- Immutability check (dev only) β warns if state is mutated outside reducers
Q: Can you use RTK Query with a REST and a GraphQL API at the same time?
Yes. Create two separate createApi instances with different reducerPath values and baseQuery configurations. Add both reducers and both middleware to configureStore.
This guide covers Redux from first principles through RTK Query advanced patterns. Practice: build a Todo app with plain Redux β migrate it to RTK β add RTK Query for server-synced todos.