~/.claude/skills/ or any Agent-Skills-compatible runtime for AI-assisted Actions work.
Actions
- Plain objects describing “what happened”
- Only source of information for the store
- Enables complete audit trail of state changes
Key Insight
Actions are the “events” in your application’s event-driven architecture—they don’t tell the state how to change, they simply announce “user clicked login button” or “API returned product list.” This declarative approach creates a complete audit trail of everything that happened in your app, powering time-travel debugging, analytics, and even replaying user sessions for bug reproduction. Think of actions as your app’s historical record: every action is a timestamped event that, when replayed in sequence, reconstructs the exact state at any point in time.
Detailed Description
Actions in frontend state management are plain JavaScript objects that describe changes to be made to the application’s state. They are the primary way to interact with the store and trigger state updates. Unlike imperative approaches where you directly modify state (“set user to X”), actions are descriptive commands (“user logged in with credentials X”).
The Redux architecture mandates that actions are the only way to change state. This constraint seems restrictive but is incredibly powerful: it means every state change is logged, trackable, and reproducible. Actions flow from UI events, API responses, timers, or any other source, through middleware, to reducers that update state.
Key characteristics:
- Plain objects - Serializable JavaScript objects (no functions, classes, or Promises)
- Type property - String constant identifying the action (convention: ‘domain/eventName’)
- Payload - Additional data needed for state update (optional but common)
- FSA compliance - Follow Flux Standard Action format for consistency
- Immutable - Action objects shouldn’t be modified after creation
With those rules in mind, let’s see how the same intent gets expressed across the six template families.
Code Examples
Basic Example: Actions across state libraries
The same todo-CRUD actions, authored idiomatically in each of the state libraries the six chota-* templates ship. Every flavour here produces the same effect on the store — they differ only in ceremony and in where the “type + payload” envelope is constructed.
Classic Redux
// templates/chota-react-redux/src/state/todo/todo.actions.js
import { CREATE_TODO, DELETE_TODO, EDIT_TODO, TOGGLE_TODO, UPDATE_TODO } from "./todo.type";
let nextTodoId = 0;
export const createTodo = (text) => ({
type: CREATE_TODO,
payload: { id: nextTodoId++, text },
});
export const editTodo = (payload) => ({ type: EDIT_TODO, payload });
export const updateTodo = (payload) => ({ type: UPDATE_TODO, payload });
export const deleteTodo = (id) => ({ type: DELETE_TODO, payload: { id } });
export const toggleTodo = (payload) => ({ type: TOGGLE_TODO, payload });
Redux Toolkit
// templates/chota-react-rtk/src/state/todo/todo.reducer.js
// RTK's createSlice auto-generates action creators and types from reducer names.
import { createSlice } from "@reduxjs/toolkit";
import initialTodoState from "./todo.initial";
let nextTodoId = 0;
export const todoSlice = createSlice({
name: "todo",
initialState: initialTodoState,
reducers: {
createTodo: (state, action) => {
state.todoItems.push({ text: action.payload, completed: false, id: nextTodoId++ });
},
editTodo: (state, action) => { state.currentTodoItem = action.payload; },
toggleTodo: (state, action) => {
state.todoItems = state.todoItems.map((t) =>
t.id === action.payload.id ? { ...t, completed: !t.completed } : t);
},
},
});
// Action creators are generated for each case reducer:
export const { createTodo, editTodo, toggleTodo } = todoSlice.actions;
Redux Saga
// templates/chota-react-saga/src/state/todo/todo.actions.js
// Plain action creators. Sagas listen for these and dispatch *Success / *Error follow-ups.
export const createTodo = (payload) => ({ type: CREATE_TODO, payload });
export const createTodoSuccess = (payload) => ({ type: CREATE_TODO_SUCCESS, payload });
export const createTodoError = (error) => ({ type: CREATE_TODO_ERROR, error });
export const readTodo = () => ({ type: READ_TODO });
export const readTodoSuccess = (payload) => ({ type: READ_TODO_SUCCESS, payload });
export const readTodoError = (error) => ({ type: READ_TODO_ERROR, error });
export const deleteTodo = (payload) => ({ type: DELETE_TODO, payload });
export const deleteTodoSuccess = () => ({ type: DELETE_TODO_SUCCESS });
export const deleteTodoError = (previousState, error) => ({
type: DELETE_TODO_ERROR,
previousState,
error,
});
NgRx
// templates/chota-angular-ngrx/src/app/state/todo/todo.actions.ts
import { createAction, props } from '@ngrx/store';
export const createTodo = createAction(
'[Todo] CreateTodo',
props<{ id: number; text: string }>()
);
export const editTodo = createAction(
'[Todo] EditTodo',
props<{ id: number; text: string }>()
);
export const toggleTodo = createAction(
'[Todo] ToggleTodo',
props<{ id: number }>()
);
export const deleteTodo = createAction(
'[Todo] DeleteTodo',
props<{ id: number }>()
);
// Request/Success/Fail triples drive NgRx effects:
export const loadTodosRequest = createAction('[Todo] LoadTodosRequest');
export const loadTodosSuccess = createAction('[Todo] LoadTodosSuccess');
export const loadTodosFail = createAction(
'[Todo] LoadTodosFail',
props<{ error: string }>()
);
Pinia
// templates/chota-vue-pinia/src/state/todo/todo.actions.js
// Pinia "actions" are just methods on the store — they mutate state directly.
// There's no separate "action object" envelope.
export function createTodo(text) {
this.isLoading = true;
this.isActionLoading = true;
this.currentTodoItem = { text, completed: false };
}
export function createTodoSuccess(payload) {
this.isLoading = false;
this.todoItems.push({ id: payload.id, text: payload.text, completed: false });
this.currentTodoItem = initialTodoState.currentTodoItem;
}
// Wired into the store via defineStore('todo', { actions: { createTodo, ... } }).
Redux Saga (Web Components)
// templates/chota-wc-saga/src/state/todo/todo.actions.js
// Same plain-object shape as the React Saga template — the WC template reuses
// the request/success/error triple because the pattern is framework-agnostic.
export const createTodo = (text) => ({
type: CREATE_TODO,
payload: { text, completed: false },
});
export const createTodoSuccess = (payload) => ({ type: CREATE_TODO_SUCCESS, payload });
export const createTodoError = (error) => ({ type: CREATE_TODO_ERROR, error });
export const toggleTodo = (payload) => ({ type: TOGGLE_TODO, payload });
export const toggleTodoSuccess = () => ({ type: TOGGLE_TODO_SUCCESS });
What to notice:
- Redux / Saga / WC-Saga all ship the same primitive: plain
{ type, payload }objects and little creator functions. Saga variants add explicit*Success/*Errorfollow-ups because the middleware expects them. - RTK hides both the type constants and the plain-object creators — you only write the reducer case names, and it synthesises everything.
- NgRx uses
createAction+props<...>()to give you a typed creator that still produces a conventional{ type, ...payload }action object. - Pinia is the odd one out: it has no action objects at all. Actions are methods on the store that mutate
this— the devtools still show them as discrete events, but you don’t author a creator.
Synchronous creators are only half the story — most real apps need to dispatch actions in response to network calls, which is where thunks come in.
Practical Example: Async Action Creators (Thunks)
// Synchronous action creators
export const fetchUsersStart = () => ({ type: 'users/fetchStart' });
export const fetchUsersSuccess = (users) => ({
type: 'users/fetchSuccess',
payload: users
});
export const fetchUsersFailure = (error) => ({
type: 'users/fetchFailure',
payload: error,
error: true // FSA standard for errors
});
// Async action creator (thunk)
export const fetchUsers = () => async (dispatch, getState) => {
// Check if already loaded
const { users } = getState();
if (users.items.length > 0 && !users.stale) {
return; // Skip fetch if cached
}
dispatch(fetchUsersStart());
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
dispatch(fetchUsersSuccess(data));
} catch (error) {
dispatch(fetchUsersFailure(error.message));
}
};
// Usage
function UsersList() {
const dispatch = useDispatch();
const { items, loading, error } = useSelector(state => state.users);
useEffect(() => {
dispatch(fetchUsers()); // Dispatch async thunk
}, [dispatch]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return (
<ul>
{items.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Once thunks feel natural, RTK’s createAction and createAsyncThunk collapse most of the boilerplate into a few one-liners while staying FSA-compliant.
Advanced Example: Flux Standard Actions with Redux Toolkit
import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
// Simple actions with createAction
export const incrementBy = createAction('counter/incrementBy');
// Automatically generates type and payload
// With payload preparation
export const addTodo = createAction('todos/add', (text) => {
return {
payload: {
id: nanoid(),
text,
completed: false,
createdAt: new Date().toISOString()
}
};
});
// Usage
dispatch(incrementBy(5)); // { type: 'counter/incrementBy', payload: 5 }
dispatch(addTodo('Buy milk')); // Full payload auto-generated
// Async actions with createAsyncThunk
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
},
{
condition: (userId, { getState }) => {
// Cancel if already loading
const { users } = getState();
if (users.loading === 'pending') {
return false;
}
}
}
);
// Auto-generates three action types:
// - 'users/fetchById/pending'
// - 'users/fetchById/fulfilled'
// - 'users/fetchById/rejected'
// Handle in slice
import { createSlice } from '@reduxjs/toolkit';
const usersSlice = createSlice({
name: 'users',
initialState: { entities: {}, loading: 'idle', error: null },
reducers: {
// Sync reducers
},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = 'pending';
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.entities[action.payload.id] = action.payload;
state.loading = 'idle';
})
.addCase(fetchUserById.rejected, (state, action) => {
state.error = action.payload;
state.loading = 'idle';
});
}
});
// Advanced: Action batching for performance
import { batch } from 'react-redux';
export const loadDashboard = () => (dispatch) => {
batch(() => {
// Multiple dispatches batched into single render
dispatch(fetchUsers());
dispatch(fetchPosts());
dispatch(fetchComments());
});
};
// Conditional actions
export const likePost = (postId) => (dispatch, getState) => {
const state = getState();
const post = state.posts.items.find(p => p.id === postId);
if (post.likedBy.includes(state.auth.userId)) {
dispatch({ type: 'posts/unlike', payload: postId });
} else {
dispatch({ type: 'posts/like', payload: postId });
}
};
Even with the right tooling in hand, a few action-shape pitfalls keep showing up across teams — here are the ones that bite most often.
Common Mistakes
1. Including Non-Serializable Data in Actions
Mistake: Putting functions, Promises, or class instances in action payload.
// ❌ BAD: Non-serializable data
dispatch({
type: 'users/set',
payload: new User({ id: 1, name: 'Alice' }) // Class instance
});
dispatch({
type: 'data/fetch',
payload: fetch('/api/data') // Promise
});
dispatch({
type: 'callback/set',
payload: () => console.log('done') // Function
});
// Breaks Redux DevTools, persistence, time-travel debugging
// ✅ GOOD: Plain serializable objects
dispatch({
type: 'users/set',
payload: { id: 1, name: 'Alice' } // Plain object
});
// Handle async in thunks
const fetchData = () => async (dispatch) => {
const data = await fetch('/api/data');
dispatch({ type: 'data/set', payload: data }); // Only plain data
};
// Store callback IDs, not functions
dispatch({
type: 'callback/register',
payload: { callbackId: 'onComplete' } // Reference, not function
});
Why it matters: Redux requires serializable actions for DevTools, persistence, and debugging. Non-serializable data breaks these features.
2. Putting Logic in Action Creators
Mistake: Complex business logic in action creators instead of reducers/middleware.
// ❌ BAD: Logic in action creator
function updateUserAge(userId, newAge) {
const users = store.getState().users; // Accessing store directly!
const user = users.find(u => u.id === userId);
if (user.age === newAge) {
return { type: 'NO_OP' }; // Conditional logic
}
const canUpdate = newAge > 0 && newAge < 150; // Validation
if (!canUpdate) {
return {
type: 'users/updateFailed',
payload: 'Invalid age'
};
}
return {
type: 'users/updateAge',
payload: { userId, newAge, updatedAt: Date.now() }
};
}
// ✅ GOOD: Simple action creator, logic in reducer/middleware
function updateUserAge(userId, newAge) {
return {
type: 'users/updateAge',
payload: { userId, newAge }
};
}
// Validation in reducer
function usersReducer(state, action) {
if (action.type === 'users/updateAge') {
const { userId, newAge } = action.payload;
// Validation logic here
if (newAge <= 0 || newAge >= 150) {
return state; // Ignore invalid updates
}
return {
...state,
items: state.items.map(u =>
u.id === userId ? { ...u, age: newAge } : u
)
};
}
return state;
}
Why it matters: Action creators should create actions, not contain business logic. Logic belongs in reducers (sync) or middleware (async).
3. Inconsistent Action Naming
Mistake: No naming convention for action types.
// ❌ BAD: Inconsistent naming
const actions = {
addUser: { type: 'ADD_USER' }, // SCREAMING_SNAKE_CASE
deleteUser: { type: 'user-delete' }, // kebab-case
UpdateUser: { type: 'updateUser' }, // camelCase
user_fetch: { type: 'FETCH' } // snake_case, ambiguous type
};
// Hard to track, error-prone
// ✅ GOOD: Consistent naming (domain/event)
const actions = {
addUser: { type: 'users/add' },
deleteUser: { type: 'users/delete' },
updateUser: { type: 'users/update' },
fetchUser: { type: 'users/fetch' }
};
// Or Redux Toolkit approach
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
add: (state, action) => { /* ... */ }, // Auto-generates 'users/add'
delete: (state, action) => { /* ... */ }, // Auto-generates 'users/delete'
update: (state, action) => { /* ... */ } // Auto-generates 'users/update'
}
});
Why it matters: Consistent naming improves debugging, reduces typos, and makes action logs readable. Convention: domain/event (e.g., users/add, cart/checkout).
Quick Quiz
References
- Redux: Actions — the canonical definition of actions and the
{ type, payload }envelope. - Flux Standard Action (FSA) — the community contract for the action shape that RTK and most middleware assume.
- Redux Toolkit:
createActionandcreateAsyncThunk— FSA-compliant creators and the lifecycle-action triple for async work. - NgRx:
createActionand props — typed action creators for Angular feature slices. - Pinia: Actions — why Pinia “actions” are store methods rather than dispatched objects.
- Redux Style Guide: action types as
domain/event— naming conventions and the events-not-setters mindset.