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
// Synchronous action creatorsexportconstfetchUsersStart=()=>({type:'users/fetchStart'});exportconstfetchUsersSuccess=(users)=>({type:'users/fetchSuccess',payload:users});exportconstfetchUsersFailure=(error)=>({type:'users/fetchFailure',payload:error,error:true// FSA standard for errors});// Async action creator (thunk)exportconstfetchUsers=()=>async(dispatch,getState)=>{// Check if already loadedconst{users}=getState();if(users.items.length>0&&!users.stale){return;// Skip fetch if cached}dispatch(fetchUsersStart());try{constresponse=awaitfetch('/api/users');if(!response.ok){thrownewError(`HTTP ${response.status}`);}constdata=awaitresponse.json();dispatch(fetchUsersSuccess(data));}catch(error){dispatch(fetchUsersFailure(error.message));}};// UsagefunctionUsersList(){constdispatch=useDispatch();const{items,loading,error}=useSelector(state=>state.users);useEffect(()=>{dispatch(fetchUsers());// Dispatch async thunk},[dispatch]);if(loading)return<Spinner/>;if(error)return<Errormessage={error}/>;
return(<ul>{items.map(user=><likey={user.id}>{user.name}</li>)}
</ul>
);}
Advanced Example: Flux Standard Actions with Redux Toolkit
import{createAction,createAsyncThunk}from'@reduxjs/toolkit';// Simple actions with createActionexportconstincrementBy=createAction('counter/incrementBy');// Automatically generates type and payload// With payload preparationexportconstaddTodo=createAction('todos/add',(text)=>{return{payload:{id:nanoid(),text,completed:false,createdAt:newDate().toISOString()}};});// Usagedispatch(incrementBy(5));// { type: 'counter/incrementBy', payload: 5 }dispatch(addTodo('Buy milk'));// Full payload auto-generated// Async actions with createAsyncThunkexportconstfetchUserById=createAsyncThunk('users/fetchById',async(userId,{rejectWithValue})=>{try{constresponse=awaitfetch(`/api/users/${userId}`);if(!response.ok){thrownewError('Failed to fetch user');}returnawaitresponse.json();}catch(error){returnrejectWithValue(error.message);}},{condition:(userId,{getState})=>{// Cancel if already loadingconst{users}=getState();if(users.loading==='pending'){returnfalse;}}});// Auto-generates three action types:// - 'users/fetchById/pending'// - 'users/fetchById/fulfilled'// - 'users/fetchById/rejected'// Handle in sliceimport{createSlice}from'@reduxjs/toolkit';constusersSlice=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 performanceimport{batch}from'react-redux';exportconstloadDashboard=()=>(dispatch)=>{batch(()=>{// Multiple dispatches batched into single renderdispatch(fetchUsers());dispatch(fetchPosts());dispatch(fetchComments());});};// Conditional actionsexportconstlikePost=(postId)=>(dispatch,getState)=>{conststate=getState();constpost=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});}};
Common Mistakes
1. Including Non-Serializable Data in Actions
Mistake: Putting functions, Promises, or class instances in action payload.
// ✅ GOOD: Plain serializable objectsdispatch({type:'users/set',payload:{id:1,name:'Alice'}// Plain object});// Handle async in thunksconstfetchData=()=>async(dispatch)=>{constdata=awaitfetch('/api/data');dispatch({type:'data/set',payload:data});// Only plain data};// Store callback IDs, not functionsdispatch({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 creatorfunctionupdateUserAge(userId,newAge){constusers=store.getState().users;// Accessing store directly!constuser=users.find(u=>u.id===userId);if(user.age===newAge){return{type:'NO_OP'};// Conditional logic}constcanUpdate=newAge>0&&newAge<150;// Validationif(!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/middlewarefunctionupdateUserAge(userId,newAge){return{type:'users/updateAge',payload:{userId,newAge}};}// Validation in reducerfunctionusersReducer(state,action){if(action.type==='users/updateAge'){const{userId,newAge}=action.payload;// Validation logic hereif(newAge<=0||newAge>=150){returnstate;// Ignore invalid updates}return{...state,items:state.items.map(u=>u.id===userId?{...u,age:newAge}:u)};}returnstate;}
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 namingconstactions={addUser:{type:'ADD_USER'},// SCREAMING_SNAKE_CASEdeleteUser:{type:'user-delete'},// kebab-caseUpdateUser:{type:'updateUser'},// camelCaseuser_fetch:{type:'FETCH'}// snake_case, ambiguous type};// Hard to track, error-prone