Redux Toolkit + RTK Query with tag invalidation and optimistic updates, tested with MSW: working versions (tested 2026-10-15)
An RTK Query API slice with auth headers from Redux state, tag-based cache invalidation and an optimistic update with rollback, tested offline in Vitest against MSW 3 request handlers without rendering React.
passed4 passed · 0 failed2026-10-15 14:17 UTC- Status
- passed
- Last passed
- 2026-10-15 14:17 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38748988915
The code
Exactly the files that passed. Your agent can pull them with get_recipe("redux-toolkit-rtk-query-msw") (install), or see them on GitHub.
{
"name": "redux-toolkit-rtk-query-msw",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run --reporter=default --reporter=json --outputFile=test-report.json"
},
"dependencies": {
"@reduxjs/toolkit": "2.13.0"
},
"devDependencies": {
"msw": "3.0.3",
"vitest": "5.0.4",
"@types/node": "26.6.5"
}
}
import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query";
export type Todo = { id: number; title: string; done: boolean };
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({
baseUrl: "https://api.example.test/",
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) headers.set("authorization", `Bearer ${token}`);
return headers;
},
}),
tagTypes: ["Todo"],
endpoints: (b) => ({
getTodos: b.query<Todo[], void>({
query: () => "todos",
providesTags: (r) => [...(r ?? []).map(({ id }) => ({ type: "Todo" as const, id })), { type: "Todo" as const, id: "LIST" }],
}),
addTodo: b.mutation<Todo, { title: string }>({
query: (body) => ({ url: "todos", method: "POST", body }),
invalidatesTags: [{ type: "Todo", id: "LIST" }], // refetch the list after a create
}),
toggleTodo: b.mutation<Todo, Todo>({
query: (t) => ({ url: `todos/${t.id}`, method: "PATCH", body: { done: !t.done } }),
// Optimistic update: flip the cached item now, undo if the server says no.
async onQueryStarted(t, { dispatch, queryFulfilled }) {
const patch = dispatch(api.util.updateQueryData("getTodos", undefined, (draft) => {
const item = draft.find((d) => d.id === t.id);
if (item) item.done = !t.done;
}));
try { await queryFulfilled; } catch { patch.undo(); }
},
}),
}),
});
const auth = createSlice({
name: "auth",
initialState: { token: null as string | null },
reducers: { signedIn: (s, a: PayloadAction<string>) => { s.token = a.payload; } },
});
export const { signedIn } = auth.actions;
export function makeStore() {
return configureStore({
reducer: { auth: auth.reducer, [api.reducerPath]: api.reducer },
middleware: (gdm) => gdm().concat(api.middleware),
});
}
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { api, makeStore, signedIn, type Todo } from "../src/store.ts";
const seed = (): Todo[] => [{ id: 1, title: "seed", done: false }];
let todos: Todo[] = seed();
const seen: { method: string; auth: string | null }[] = [];
const server = setupServer(
http.get("https://api.example.test/todos", ({ request }) => {
seen.push({ method: "GET", auth: request.headers.get("authorization") });
return HttpResponse.json(todos);
}),
http.post("https://api.example.test/todos", async ({ request }) => {
const body = (await request.json()) as { title: string };
const todo = { id: todos.length + 1, title: body.title, done: false };
todos.push(todo);
seen.push({ method: "POST", auth: request.headers.get("authorization") });
return HttpResponse.json(todo, { status: 201 });
}),
http.patch("https://api.example.test/todos/:id", async ({ params, request }) => {
const t = todos.find((x) => x.id === Number(params.id));
if (!t) return HttpResponse.json({ message: "not found" }, { status: 404 });
Object.assign(t, await request.json());
return HttpResponse.json(t);
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); todos = seed(); seen.length = 0; });
afterAll(() => server.close());
describe("RTK Query with MSW", () => {
it("fetches into the cache and sends the auth header from state", async () => {
const store = makeStore();
store.dispatch(signedIn("t0k"));
const r = await store.dispatch(api.endpoints.getTodos.initiate());
expect(r.data).toEqual([{ id: 1, title: "seed", done: false }]);
expect(seen).toEqual([{ method: "GET", auth: "Bearer t0k" }]);
// A second subscriber is served from cache, no new request.
await store.dispatch(api.endpoints.getTodos.initiate());
expect(seen).toHaveLength(1);
});
it("a mutation invalidates the list tag and triggers a refetch", async () => {
const store = makeStore();
const sub = store.dispatch(api.endpoints.getTodos.initiate());
await sub;
await store.dispatch(api.endpoints.addTodo.initiate({ title: "write tests" }));
await expect.poll(() => api.endpoints.getTodos.select()(store.getState()).data?.length).toBe(2);
expect(seen.map((s) => s.method)).toEqual(["GET", "POST", "GET"]);
sub.unsubscribe();
});
it("optimistic toggle shows immediately and rolls back on a server error", async () => {
const store = makeStore();
const sub = store.dispatch(api.endpoints.getTodos.initiate());
await sub;
server.use(http.patch("https://api.example.test/todos/:id", () => HttpResponse.json({ message: "nope" }, { status: 500 })));
const pending = store.dispatch(api.endpoints.toggleTodo.initiate({ id: 1, title: "seed", done: false }));
expect(api.endpoints.getTodos.select()(store.getState()).data?.[0].done).toBe(true); // optimistic
const r = await pending;
expect("error" in r && (r.error as { status: number }).status).toBe(500);
expect(api.endpoints.getTodos.select()(store.getState()).data?.[0].done).toBe(false); // rolled back
sub.unsubscribe();
});
it("surfaces HTTP errors as { status, data } on the query result", async () => {
server.use(http.get("https://api.example.test/todos", () => HttpResponse.json({ message: "down" }, { status: 503 })));
const r = await makeStore().dispatch(api.endpoints.getTodos.initiate());
expect(r.error).toEqual({ status: 503, data: { message: "down" } });
});
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
ky HTTP client with hooks and retries, tested with MSW + Vitest
A ky 2 API client (prefix option, state-object hooks) with auth hooks, retry on 5xx and typed JSON, tested offline with MSW's Node server in Vitest.
Astro 7 component rendering tests with the Container API (Vitest)
Render Astro components to HTML in Vitest with the Container API and getViteConfig, checking props, slots and conditional markup.
AWS SDK v3 DynamoDB document client and SQS events, tested with Vitest matchers
A single-table DynamoDB order service on @aws-sdk/lib-dynamodb with conditional puts, begins_with queries and optimistic locking, publishing SQS events, tested offline with aws-sdk-client-mock and aws-sdk-client-mock-vitest matchers.
Effect 4 Schema classes, typed errors and retries, tested with @effect/vitest
Effect 4 Schema.Class models with checks, brands, FiniteFromString and fromJsonString for decode and encode, TaggedError failures, and Effect.retry with exponential backoff verified on the TestClock using @effect/vitest.
All @reduxjs/toolkit recipesAll MSW recipesAll vitest recipes