~/.claude/skills/ or any Agent-Skills-compatible runtime for AI-assisted AJAX work.
AJAX
- Asynchronous server requests without full page reloads
- Fetched data feeds the store via request/success/fail action triples
- Lives in operations, sagas, effects, or Pinia actions — never in reducers
Detailed Description
AJAX (Asynchronous JavaScript And XML) is the umbrella term for any client-side HTTP request made without navigating the browser. Two browser APIs implement it: the legacy XMLHttpRequest (event-driven, verbose, but supports upload progress and synchronous mode) and the modern fetch (Promise-based, streams-aware, the default in every template here). Libraries like Axios wrap one of those two; under the hood it’s still XHR or fetch.
In this repo, AJAX never appears inside a reducer — reducers stay pure. Instead, network calls live in the side-effect layer for each framework: src/state/<slice>/<slice>.operations.js for Redux Saga and Pinia, <slice>.effects.ts for NgRx, and (when added) thunks/createAsyncThunk for plain Redux and RTK. Every template imports a thin utils/api.js helper that returns a fetch-compatible Response.
Every async flow follows the same three-action shape: a request action flips a loading flag, a success action commits the payload to the store, and a fail action records the error. The try / catch around the call dispatches success on the happy path and fail in the catch — that pattern is identical across saga, effect, thunk, and Pinia operation files. Watchers (takeLatest, ofType, action-listener middleware) wire the request action to the side effect.
Fetch and XHR have one critical asymmetry worth memorising: fetch only rejects on network failures, not on HTTP error status. A 404 or 500 produces a resolved Promise whose response.ok is false, so callers must check it explicitly before parsing JSON. XHR, by contrast, surfaces no error status either — you must inspect xhr.status. Either way, “the request returned” and “the request succeeded” are different questions.
Key Insight
AJAX in a state-managed app is plumbing, not policy. The interesting design choice is where the fetch lives and which actions it dispatches — never whether to use XHR or fetch directly in a component. By pushing every network call into operations / sagas / effects / Pinia actions, the rest of the app becomes synchronous: components dispatch a request action and re-render off selectors, reducers stay pure functions of (state, action), and tests can mock the network at one well-defined seam instead of stubbing global.fetch everywhere.
Basic Example
The same “load todos from the server” flow expressed in each template’s idiom. The watcher / effect / store-action listens for the request action, hits the API, then dispatches success or fail.
React Redux
// templates/chota-react-redux/src/state/todo/todo.actions.js (thunk form)
// Plain Redux uses redux-thunk: an action creator returns a function
// that receives dispatch, calls fetch, and dispatches the triple.
import fetchApi from "../../utils/api";
import { READ_TODO_SUCCESS, READ_TODO_ERROR } from "./todo.type";
export const readTodos = () => async (dispatch) => {
dispatch({ type: "READ_TODO" });
try {
const res = await fetchApi("/todos");
const data = await res.json();
dispatch({ type: READ_TODO_SUCCESS, payload: data });
} catch (error) {
dispatch({ type: READ_TODO_ERROR, payload: error.toString() });
}
};
React RTK
// templates/chota-react-rtk/src/state/todo/todo.actions.js (createAsyncThunk)
// RTK collapses request/success/fail into pending/fulfilled/rejected
// lifecycle actions on a single thunk.
import { createAsyncThunk } from "@reduxjs/toolkit";
import fetchApi from "../../utils/api";
export const readTodos = createAsyncThunk(
"todo/read",
async (_, { rejectWithValue }) => {
const res = await fetchApi("/todos");
if (!res.ok) return rejectWithValue(`HTTP ${res.status}`);
return res.json();
}
);
React Saga
// templates/chota-react-saga/src/state/todo/todo.operations.js
import { put, takeLatest, call } from "redux-saga/effects";
import { READ_TODO } from "./todo.type";
import { readTodoSuccess, readTodoError } from "./todo.actions";
import fetchApi from "../../utils/api";
export function getTodoApi() { return fetchApi("/todos"); }
export function* getTodos() {
try {
const res = yield call(getTodoApi);
const data = yield res.json();
yield put(readTodoSuccess(data));
} catch (error) {
yield put(readTodoError(error.toString()));
}
}
export function* watchTodos() { yield takeLatest(READ_TODO, getTodos); }
NgRx (Angular)
// templates/chota-angular-ngrx/src/app/state/todo/todo.effects.ts
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { switchMap, mergeMap, catchError } from 'rxjs/operators';
import * as TodoActions from './todo.actions';
loadTodosRequest$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadTodosRequest),
switchMap(() =>
this.todoService.getAllTodos().pipe(
mergeMap((todos) => [TodoActions.loadTodos({ todos }),
TodoActions.loadTodosSuccess()]),
catchError((error) =>
of(TodoActions.loadTodosFail({ error: error.message })))
))));
Pinia (Vue)
// templates/chota-vue-pinia/src/state/todo/todo.operations.js
import { readTodo, readTodoSuccess, readTodoError } from "./todo.actions";
import { mapTodoData } from "./todo.helper";
import fetchApi from "../../utils/api";
export async function getTodos() {
try {
readTodo.bind(this)();
const res = await fetchApi("/todos");
const data = await res.json();
readTodoSuccess.bind(this)(mapTodoData(data));
} catch (error) {
readTodoError.bind(this)(error.toString());
}
}
Practical Example
A createAsyncThunk that fetches todos, with explicit response.ok checking and AbortController wired through RTK’s signal. The slice’s extraReducers reacts to the three lifecycle actions RTK generates automatically.
// src/state/todo/todo.thunks.js
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
export const fetchTodos = createAsyncThunk(
"todo/fetch",
async (_, { signal, rejectWithValue }) => {
try {
const res = await fetch("/api/todos", { signal });
if (!res.ok) {
return rejectWithValue({ status: res.status, message: res.statusText });
}
return await res.json();
} catch (error) {
if (error.name === "AbortError") throw error; // let RTK mark it canceled
return rejectWithValue({ message: error.message });
}
}
);
const todoSlice = createSlice({
name: "todo",
initialState: { items: [], status: "idle", error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (s) => { s.status = "loading"; s.error = null; })
.addCase(fetchTodos.fulfilled, (s, a) => { s.status = "ready"; s.items = a.payload; })
.addCase(fetchTodos.rejected, (s, a) => {
s.status = "error";
s.error = a.payload ?? a.error.message;
});
},
});
export default todoSlice.reducer;
Common Mistakes
1. Treating a 4xx/5xx response as success
// BAD — fetch resolves on HTTP 500, so this swallows server errors
const res = await fetch("/api/todos");
const data = await res.json(); // may throw, or worse: parse an error body
dispatch(readTodoSuccess(data));
// GOOD — gate on response.ok before parsing
const res = await fetch("/api/todos");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dispatch(readTodoSuccess(await res.json()));
Why it matters: fetch only rejects on network failures. Without an ok check, error pages get parsed as data and your reducer ends up with garbage state.
2. Leaking requests / race conditions
// BAD — older request can resolve last and clobber newer data
useEffect(() => { dispatch(searchUsers(query)); }, [query]);
// GOOD — abort the previous request when query changes
useEffect(() => {
const ctrl = new AbortController();
dispatch(searchUsers({ query, signal: ctrl.signal }));
return () => ctrl.abort();
}, [query]);
Why it matters: Without cancellation, a slow earlier response overwrites the latest one and the UI shows stale results — takeLatest solves this in saga, switchMap in NgRx, AbortController in thunks.
3. Calling fetch inside a reducer
// BAD — reducers must be pure; this breaks DevTools, tests, and SSR
case READ_TODO:
fetch("/api/todos").then(r => r.json()); // side effect in reducer
return state;
// GOOD — fetch lives in the side-effect layer; reducer just handles success
case READ_TODO_SUCCESS:
return { ...state, items: action.payload };
Why it matters: Reducers are run during time-travel, hydration, and tests. Any I/O inside them re-fires on replay, makes them non-deterministic, and shatters the mental model of (state, action) => state.