~/.claude/skills/ or any Agent-Skills-compatible runtime for AI-assisted Operations work.
Operations
Glossary
- Operation — an async function that wires an action to an API call and dispatches request/success/fail follow-ups.
- Side effect — anything outside reducer purity: network, timers, storage, navigation.
- Lifecycle triple — the request / success / fail action trio that drives loading, data, and error UI states.
Detailed Description
An operation is the async layer between a UI event and the store. The component dispatches a plain action (“user clicked add”); the operation runs the fetch, awaits the response, and dispatches the resulting success or error action. Reducers stay pure; everything that touches the network lives here.
In this repo, operations live next to their slice. Saga and Pinia name the file todo.operations.js; RTK uses createAsyncThunk; classic Redux uses a hand-written thunk; NgRx uses createEffect in todo.effects.ts. Same role, different ceremony.
The distinction matters: actions describe what happened, operations describe how to get there. Separating them is what makes reducers replayable and devtools time-travel possible — the network round-trip is fully reified as the action triple it produced.
Operations also own the cross-cutting concerns of async UX: loading flags, optimistic updates with rollback (Saga and Pinia capture previousStateTodoItems before the request), and cancellation when a newer request supersedes an older one (takeLatest in sagas, switchMap in NgRx effects).
Key Insight
Reducers are the what; operations are the how. Move every await and try/catch into operations and reducers stay as pure functions of (state, action) — fully testable and replayable — while the running app still gets real network behaviour, optimistic updates, and cancellation.
Basic Example
The same “add a todo” operation in all five state libraries — each fires a request, calls the API, dispatches success or error.
React Redux
// templates/chota-react-redux/src/state/todo/todo.actions.js
export const createTodo = (text) => ({ type: CREATE_TODO, payload: { text } });
export const createTodoSuccess = (todo) => ({ type: CREATE_TODO_SUCCESS, payload: todo });
export const createTodoError = (e) => ({ type: CREATE_TODO_ERROR, error: e });
export const addTodo = (text) => async (dispatch) => {
dispatch(createTodo(text));
try {
const res = await fetch("/todos", { method: "POST", body: JSON.stringify({ text }) });
dispatch(createTodoSuccess(await res.json()));
} catch (err) {
dispatch(createTodoError(err.toString()));
}
};
React RTK
// templates/chota-react-rtk/src/state/todo/todo.actions.js
import { createAsyncThunk } from "@reduxjs/toolkit";
export const addTodo = createAsyncThunk(
"todo/add",
async (text, { rejectWithValue }) => {
try {
const res = await fetch("/todos", { method: "POST", body: JSON.stringify({ text }) });
return await res.json();
} catch (err) {
return rejectWithValue(err.toString());
}
}
);
React Saga
// templates/chota-react-saga/src/state/todo/todo.operations.js
import { put, takeLatest, call } from "redux-saga/effects";
import { createTodoSuccess, createTodoError } from "./todo.actions";
import fetchApi from "../../utils/api";
export function addTodoApi(payload) {
return fetchApi("/todos", { method: "POST", body: payload });
}
export function* addTodos(action) {
try {
const payload = { ...action.payload, id: window.crypto.randomUUID() };
yield call(addTodoApi, payload);
yield put(createTodoSuccess(payload));
} catch (error) {
yield put(createTodoError(error.toString()));
}
}
export function* watchTodos() {
yield takeLatest(CREATE_TODO, addTodos);
}
NgRx (Angular)
// templates/chota-angular-ngrx/src/app/state/todo/todo.effects.ts
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { switchMap, catchError, mergeMap } from 'rxjs/operators';
import * as TodoActions from './todo.actions';
@Injectable()
export class TodoEffects {
addTodoRequest$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.addTodoRequest),
switchMap(({ text }) =>
this.todoService.createTodo(text).pipe(
mergeMap((todo) => [
TodoActions.createTodo({ id: todo.id, text: todo.text }),
TodoActions.addTodoSuccess(),
]),
catchError((error) => of(TodoActions.addTodoFail({ error: error.message })))
)
)
)
);
constructor(private actions$: Actions, private todoService: TodoService) {}
}
Pinia (Vue)
// templates/chota-vue-pinia/src/state/todo/todo.operations.js
import { createTodo, createTodoSuccess, createTodoError } from "./todo.actions";
import fetchApi from "../../utils/api";
export function addTodoApi(payload) {
return fetchApi("/todos", { method: "POST", body: payload });
}
export async function addTodos(text) {
try {
createTodo.bind(this)(text);
const payload = { ...this.currentTodoItem, id: window.crypto.randomUUID() };
await addTodoApi(payload);
createTodoSuccess.bind(this)(payload);
} catch (error) {
createTodoError.bind(this)(error.toString());
}
}
Practical Example
Optimistic toggle from the React Saga template. The reducer flips the checkbox immediately on TOGGLE_TODO; the saga calls the API and, on failure, waits 500ms then dispatches TOGGLE_TODO_ERROR with the pre-toggle snapshot the reducer captured.
// templates/chota-react-saga/src/state/todo/todo.operations.js
import { put, takeLatest, call, select, delay } from "redux-saga/effects";
import { TOGGLE_TODO } from "./todo.type";
import { toggleTodoSuccess, toggleTodoError } from "./todo.actions";
import { toggleCheckedState } from "./todo.helper";
import fetchApi from "../../utils/api";
export function updateTodoApi(payload) {
return fetchApi("/todos", { method: "PUT", body: payload });
}
export function* updateToggleTodos(action) {
try {
// Reducer already flipped the checkbox optimistically on TOGGLE_TODO.
yield call(updateTodoApi, toggleCheckedState(action.payload));
yield put(toggleTodoSuccess());
} catch (error) {
// Rollback: brief delay so the user sees the optimistic state, then restore.
yield delay(500);
const previous = yield select((s) => s.todo.previousStateTodoItems);
yield put(toggleTodoError(previous, error.toString()));
}
}
export function* watchTodos() {
// takeLatest cancels any in-flight toggle when a newer one arrives.
yield takeLatest(TOGGLE_TODO, updateToggleTodos);
}
The pattern: optimistic update in the reducer, network call in the operation, rollback action on failure, takeLatest for cancellation.
Common Mistakes
1. Doing API calls inside reducers
Mistake: Calling fetch from a reducer to keep things “in one place.”
// BAD
function todoReducer(state, action) {
if (action.type === CREATE_TODO) {
fetch("/todos", { method: "POST", body: JSON.stringify(action.payload) }); // side effect!
return { ...state, todoItems: [...state.todoItems, action.payload] };
}
return state;
}
// GOOD — reducer stays pure; side effect lives in the operation.
function todoReducer(state, action) {
if (action.type === CREATE_TODO_SUCCESS) {
return { ...state, todoItems: [...state.todoItems, action.payload] };
}
return state;
}
export function* addTodos(action) {
try {
const saved = yield call(addTodoApi, action.payload);
yield put(createTodoSuccess(saved));
} catch (e) { yield put(createTodoError(e.toString())); }
}
Why it matters: Reducers must be pure — same input, same output, no I/O. A fetch inside one breaks devtools time-travel, replay, SSR hydration, and most testing assumptions.
2. Not cancelling when the component unmounts
Mistake: Letting an in-flight request resolve into a store that no longer cares.
// BAD: every keystroke fires; results race; stale ones win.
useEffect(() => {
dispatch(searchTodos(query));
}, [query]);
// GOOD: takeLatest in the saga cancels older requests automatically.
export function* watchSearch() {
yield takeLatest(SEARCH_TODOS, runSearch);
}
// Or with createAsyncThunk: read signal in your fetch and abort on unmount.
const searchThunk = createAsyncThunk("todo/search", async (q, { signal }) => {
const res = await fetch(`/todos?q=${q}`, { signal });
return res.json();
});
Why it matters: Without cancellation, a slow earlier request can land after a fast later one and overwrite the correct result. takeLatest (saga) and switchMap (NgRx) solve this idiomatically; thunks need an AbortController.
3. Catching errors but never dispatching the FAIL action
Mistake: Swallowing the error so the UI sticks on its loading spinner forever.
// BAD
export function* addTodos(action) {
try {
yield call(addTodoApi, action.payload);
yield put(createTodoSuccess(action.payload));
} catch (error) {
console.error(error); // logged, but the store still thinks isLoading=true
}
}
// GOOD
export function* addTodos(action) {
try {
yield call(addTodoApi, action.payload);
yield put(createTodoSuccess(action.payload));
} catch (error) {
yield put(createTodoError(error.toString())); // reducer flips isLoading=false, sets error
}
}
Why it matters: The lifecycle triple is a contract — every request must end in either success or fail. Skipping the fail dispatch leaves the UI in a permanent loading state and hides bugs from users and devtools alike.
Quick Quiz
References
- Redux Toolkit:
createAsyncThunk— the official thunk creator with auto-generated pending/fulfilled/rejected actions. - Redux Saga: effect creators —
takeLatest,call,put,selectand the cancellation primitives. - NgRx: Effects —
createEffect,switchMapfor cancellation, request/success/fail action pattern. - Pinia: Actions — why Pinia operations are just async methods on the store.
- Redux Style Guide: actions as events — the mindset that keeps operations and actions cleanly separated.
- MDN: AbortController — how to cancel a
fetchfrom a thunk when the component unmounts. - React:
useOptimistic— the hook-level counterpart to optimistic-with-rollback at the store level.
</content> </invoke>