DEV Community

Jan Hesters
Jan Hesters

Posted on

Master Redux Action Best Practices & Make Debugging Easy

I recently released the first articles and videos on my YouTube channel as part of a series about Redux.

When I talked to my mentees, some of them struggled with getting their actions right. To be fair, even though actions seem simple, small mistakes can compound and lead to significant tech debt.

So, I wanted to write this bonus micro-lesson for you to lay out the best practices for actions in detail. Everything you're about to learn applies to any action design - whether you use vanilla Redux, Redux Toolkit, or React's built-in useReducer hook.

Before we jump into it, if you haven't read part one, "What Is Redux? (Get A Senior Understanding Of How Redux Works)" and part two, "Redux Saga Is Hard Until You Look Under The Hood", open them in a new tab and read them first. Then come back. This article assumes you know Redux's six building blocks and the basics of Redux Saga.

As a quick recap, Redux actions are objects with a type and an optional payload.

const loginClicked = {
  type: 'LOGIN_CLICKED',  payload: { email: 'jan@reactsquad.io', password: '5ub5cr1b3' },};
Enter fullscreen mode Exit fullscreen mode

These actions usually come from action creators, which are functions that return action objects.

const loginClicked = (email, password) => ({
  type: 'LOGIN_CLICKED',  payload: { email, password },});
Enter fullscreen mode Exit fullscreen mode

Descriptive Names

The first best practice is that your actions should have descriptive names. These names should describe what happened in your app rather than what the action does. This helps with debugging. Most people name their actions after what the action does, which makes it harder to understand their role in the context of your app.

Let me give you a real-world example that came up with one of my mentees. He wanted to practice what he had learned in the Redux videos and built a typing trainer game. In that game, the user types certain strings containing many special characters. With each keystroke, the game checks whether the user typed the correct characters, and if that user got everything right, it saves the level.

In the beginning, he designed his state with a single setInputString action.

export const setInputString = payload => ({
  type: 'SET_INPUT_STRING',  payload,});export const sliceName = 'typeTrainer';const initialState = {
  inputString: '',  levelString: '{}/^%*#!)@(',};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case setInputString().type: {
      return { ...state, inputString: payload };    }
    default: {
      return state;    }
  }
};export const selectTypeTrainerSlice = state => state[sliceName];export const selectInputString = state =>  selectTypeTrainerSlice(state).inputString;export const selectLevelString = state =>  selectTypeTrainerSlice(state).levelString;
Enter fullscreen mode Exit fullscreen mode

He created a setInputString action that was dispatched whenever the user typed in the input field.

The state contained the current input string and the level string. The level string represented the text the user had to type correctly for each level.

In the reducer, he updated the state with the new input string.

Furthermore, he had selectors to read both the current input string and the level string.

This state is highly simplified because I only want to illustrate the importance of descriptive action names.

He also had a saga that listened for all changes to the input string and checked whether the user typed the correct characters.

import { put, takeLeading } from 'redux-saga/effects';import {
  selectInputString,  selectLevelString,  setInputString,} from './type-trainer-reducer';export function* handleInputString() {
  const inputString = yield select(selectInputString);  const levelString = yield select(selectLevelString);  if (inputString === levelString) {
    // Perform side effects like showing confetti or recording CPM.    // Example: yield put(showConfetti);    // Example: yield call(recordCharactersPerMinute);    // Reset the input string.    yield put(setInputString(''));  }
}
export function* watchInputString() {
  yield takeLeading(setInputString.type, handleSetInputString);}
Enter fullscreen mode Exit fullscreen mode

The saga would listen to all changes in the input string and check whether the user typed the correct characters. If the user typed the correct characters, he used the setInputString action in the saga to reset the input string. In reality, the action was even used a third time to reset the input when the user made a mistake.

Now, here's how this example illustrates the problem with non-descriptive action names:

If there is a bug in the code, it's hard to trace where it came from. As a result, the sagas are also difficult to put into context. Right now, it looks like these sagas should run whenever the input string changes, even when triggered by themselves - such as during a reset.

To fix this issue, you should give the action a descriptive name that explains what just happened, rather than what the action does.

export const userTyped = payload => ({ type: 'USER_TYPED', payload });export const userSucceeded = () => ({ type: 'USER_SUCCEEDED' });export const sliceName = 'typeTrainer';const initialState = {
  inputString: '',  levelString: '{}/^%*#!)@(',};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case userTyped().type: {
      return { ...state, inputString: payload };    }
    case userSucceeded().type: {
      return { ...state, levelString: '' };    }
    default: {
      return state;    }
  }
};// ... selectors
Enter fullscreen mode Exit fullscreen mode

Now, the action userTyped describes what happened. Additionally, you add a second action, userSucceeded, to describe what happened when the user typed the correct characters.

You can now use these actions in your saga.

import { put, takeLeading } from 'redux-saga/effects';import {
  selectInputString,  selectLevelString,  userSucceeded,  userTyped,} from './type-trainer-reducer';export function* handleUserTyped() {
  const inputString = yield select(selectInputString);  const levelString = yield select(selectLevelString);  if (inputString === levelString) {
    // Perform side effects like showing confetti or recording CPM.    // Example: yield put(showConfetti);    // Example: yield call(recordCharactersPerMinute);    // Reset the input string.    yield put(userSucceeded());  }
}
export function* watchUserTyped() {
  yield takeLeading(userTyped.type, handleUserTyped);}
Enter fullscreen mode Exit fullscreen mode

Now you can see that the saga only listens to the userTyped action. When the user types the correct characters, the userSucceeded action is dispatched instead. This clearly communicates what the saga is doing within the app's functionality rather than just describing what the action does programmatically.

Named Parameters

Some action creators take multiple values in their payload.

const loginClicked = (email, password) => ({
  type: 'LOGIN_CLICKED',  payload: { email, password },});
Enter fullscreen mode Exit fullscreen mode

You can make action creators easier to read by using named parameters.

If you've never heard of named parameters, they allow you to specify function arguments by explicitly naming each parameter and assigning it a value when calling the function. This makes it clear which value corresponds to which parameter and allows arguments to be provided in any order.

Technically, JavaScript doesn't support named parameters, so here is an example in Python, which does support named parameters.

def login(email, password):
    # TODO: Implement the login functionality    passlogin(email="user@example.com", password="securepassword123")
Enter fullscreen mode Exit fullscreen mode

As mentioned above, you can call the function with the parameters in any order. So, both of these calls are equivalent:

login(email="user@example.com", password="securepassword123")
login(password="securepassword123", email="user@example.com")
Enter fullscreen mode Exit fullscreen mode

In JavaScript, you can emulate named parameters by using an object as the parameter.

const loginClicked = ({ email, password }) => ({
  type: 'LOGIN_CLICKED',  payload: { email, password },});
Enter fullscreen mode Exit fullscreen mode

When you now use the action, you are forced to name the arguments explicitly.

loginClicked({ email: 'jan@reactsquad.io', password: '5ub5cr1b3' });
Enter fullscreen mode Exit fullscreen mode

This makes it obvious what each argument means.

Default Values

Using named parameters via an object is also a good way to set default values for the action.

const saveProfileAvatarImage = ({ image, alt = 'Your profile avatar' }) => ({
  type: 'SAVE_PROFILE_AVATAR_IMAGE',  payload: { image, alt },});
Enter fullscreen mode Exit fullscreen mode

In this example, the alt parameter is optional. If you don't provide it, it will default to 'Your profile avatar'. This also gives you type inference for the action and your editor knows that the alt property should be a string.

Data Transformations

Action creators can transform data. For example, if you receive a date object that isn't serializable, you can convert it to a string.

const saveDate = date => ({
  type: 'SAVE_DATE',  payload: date.toISOString(),});
Enter fullscreen mode Exit fullscreen mode

As long as the action creator remains a pure function, you can freely perform data transformations and even use array methods like .map(), .filter(), .reduce(), and others. This also applies to the action handlers in your reducer's cases. And you usually perform complex transformations in your reducer case handlers. However, I wanted to point out that, in theory, you can also perform pure data transformations within your action creator.

Named Properties On Payloads

In this previous example, you saw that the action creator returns the date directly as the payload. Therefore, when you debug your Redux app, you only see that there is a date string on the object, but you don't know what it means.

action SAVE_DATE @ 12:34:56.789
  prev state  { ...previousState }
  action      {
    type: 'SAVE_DATE',
    payload: '2024-12-14T00:00:00.000Z'
  }
  next state  { ...nextState }
Enter fullscreen mode Exit fullscreen mode

You can make it possible for people to understand what the date means, by giving the payload a named property.

const saveDate = date => ({
  type: 'SAVE_DATE',  payload: { currentBackupDate: date.toISOString() },});
Enter fullscreen mode Exit fullscreen mode

When you log out the action, you see that the payload contains a currentBackupDate property.

action SAVE_DATE @ 12:34:56.789
  prev state  { ...previousState }
  action      {
    type: 'SAVE_DATE',
    payload: { currentBackupDate: '2024-12-14T00:00:00.000Z' }
  }
  next state  { ...nextState }
Enter fullscreen mode Exit fullscreen mode

This lets your coworkers - or your future self - know that the date in the payload is when the backup was created.

You can combine this with named properties for your arguments to make your actions even more expressive.

const saveDate = ({ currentBackupDate }) => ({
  type: 'SAVE_DATE',  payload: { currentBackupDate: currentBackupDate.toISOString() },});
Enter fullscreen mode Exit fullscreen mode

While this doesn't change the logging output, it makes it easier to understand what the arguments mean when you see the action in your code.

Actions Can Have Multiple Effects

Another mentee of mine created a login flow in Redux. It consisted of four actions: loginClicked, userFetched, showToast and finishLogin. He set loading when login started, set the user when fetched, and finished the login when done. He also showed a toast.

He had a reducer that would handle the login state and the user profile.

export const loginClicked = ({ email, password }) => ({
  type: 'LOGIN_CLICKED',  payload: { email, password },});export const userFetched = () => ({ type: 'USER_FETCHED' });export const finishLogin = () => ({ type: 'FINISH_LOGIN' });const initialState = {
  email: '',  password: '',  isLoading: false,  user: null,};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case loginClicked().type: {
      return { ...state, isLoading: true };    }
    case userFetched().type: {
      return { ...state, user: payload };    }
    case finishLogin().type: {
      return { ...state, isLoading: false };    }
    default: {
      return state;    }
  }
};
Enter fullscreen mode Exit fullscreen mode

This reducer was responsible for three actions: loginClicked, userFetched, and finishLogin.

When the login was clicked, he set the loading state to true. When the user was fetched, he set the user profile. And when the login was finished, he set the loading state to false.

He also had a toast reducer that would display a toast message, for example, when the login was successful.

export const showToast = message => ({ type: 'SHOW_TOAST', payload: message });const initialState = {
  message: '',};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case showToast().type: {
      return { ...state, message: payload };    }
    default: {
      return state;    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Again, I'm simplifying the code in all of these examples to illustrate the point.

He used all of these actions in his saga.

import { call, put, takeLeading } from 'redux-saga/effects';import { showToast } from './toast-reducer';import { login } from './user-authentication-api';import {
  finishLogin,  loginClicked,  userFetched,} from './user-authentication-reducer';function* handleLoginClicked({ payload: { email, password } }) {
  const user = yield call(login, email, password);  yield put(userFetched(user));  yield put(finishLogin());  yield put(showToast('Login successful'));}
export function* watchLoginClicked() {
  yield takeLeading(loginClicked().type, handleLoginClicked);}
Enter fullscreen mode Exit fullscreen mode

If you paid attention, you'll notice that he already did many things right. He named some actions descriptively, which also led to well-named sagas. He was also reusing the same actions in multiple places—for example, the loginClicked action both modified the state and started the handleLoginClicked saga. However, he structured his code in a way that required dispatching too many actions.

When the login succeeds, the only meaningful event is that the login was successful. There's no need to design the consequences so granularly.

He fixed this by creating a single action, loginSucceeded, which is dispatched when the login is successful.

export const loginClicked = ({ email, password }) => ({
  type: 'LOGIN_CLICKED',  payload: { email, password },});export const loginSucceeded = user => ({
  type: 'LOGIN_SUCCEEDED',  payload: { user },});const initialState = {
  email: '',  password: '',  isLoading: false,  user: null,};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case loginClicked().type: {
      return { ...state, isLoading: true };    }
    case loginSucceeded().type: {
      return { ...state, isLoading: false, user: payload.user };    }
    default: {
      return state;    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Notice how he designed the loginSucceeded action with named properties to take the user as an argument while still including it in the payload as a key-value pair in an object. As you learned earlier, this approach makes it easier to see what the payload contains when using logging middleware or Redux DevTools.

Now, you can use the action in the saga to consolidate two dispatch calls into one.

import { call, put, takeLeading } from 'redux-saga/effects';import { showToast } from './toast-reducer';import { login } from './user-authentication-api';import { loginClicked, loginSucceeded } from './user-authentication-reducer';function* handleLoginClicked({ payload: { email, password } }) {
  const user = yield call(login, email, password);  yield put(loginSucceeded(user));  yield put(showToast('Login successful'));}
export function* watchLoginClicked() {
  yield takeLeading(loginClicked().type, handleLoginClicked);}
Enter fullscreen mode Exit fullscreen mode

Multiple Reducers Can Handle The Same Action

You might have noticed that a separate action, showToast, is still being dispatched in the saga.

Another important best practice for actions is that it's okay for multiple reducers to handle the same action. Instead of dispatching showToast, let the toast reducer also respond to loginSucceeded:

import { loginSucceeded } from './user-authentication-reducer';export const showToast = message => ({ type: 'SHOW_TOAST', payload: message });const initialState = {
  message: '',};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case showToast().type: {
      return { ...state, message: payload };    }
    case loginSucceeded().type: {
      return { ...state, message: 'Login successful' };    }
    default: {
      return state;    }
  }
};
Enter fullscreen mode Exit fullscreen mode

You import the loginSucceeded action from user-authentication-reducer.js and create a case for it in toast-reducer.js.

The handler for the loginSucceeded action ignores the payload. You don’t need to pass a message as the payload because the toast message is always the same when login succeeds.

Now, you can remove the showToast action from the saga because loginSucceeded implicitly shows the toast.

import { call, put, takeLeading } from 'redux-saga/effects';import { login } from './user-authentication-api';import { loginClicked, loginSucceeded } from './user-authentication-reducer';function* handleLoginClicked({ payload: { email, password } }) {
  const user = yield call(login, email, password);  yield put(loginSucceeded(user));}
export function* watchLoginClicked() {
  yield takeLeading(loginClicked().type, handleLoginClicked);}
Enter fullscreen mode Exit fullscreen mode

Dispatching The Same Action In Multiple Places

When we refactored his code like this during a mentoring session, the mentee asked:

“What if the loginSucceeded action is used in multiple places? For example, if there are different sagas for different login strategies (e.g., Google, email/password, etc.)?”

The answer is that if the reducers' behavior should be the same, you can use the same action. If they are different, you should use distinct actions that describe exactly what happened differently. Instead of loginSucceeded, you could have loginSucceededWithGoogle and loginSucceededWithEmailPassword, and so on. This ties back to the first point about using descriptive action names. You will see code for this in the next section.

In conclusion, one action can either be dispatched in multiple sagas or on clicks in different places in your app. And this one action can be handled in the case of different reducers and simultaneously trigger different sagas.

Multiple Actions Causing The Same State Update (With State Update Helpers)

Going back to the example from above with different login strategies, assume that there is an email and a Google login strategy. Both should cause the same user authentication state updates (flip the boolean, set the user), but they should show different toast messages.

Here is how the reducer looks like:

export const loginWithEmailClicked = ({ email, password }) => ({
  type: 'LOGIN_WITH_EMAIL_CLICKED',  payload: { email, password },});export const loginWithGoogleClicked = () => ({
  type: 'LOGIN_WITH_GOOGLE_CLICKED',});export const loginSucceededWithEmail = user => ({
  type: 'LOGIN_SUCCEEDED_WITH_EMAIL',  payload: { user },});export const loginSucceededWithGoogle = user => ({
  type: 'LOGIN_SUCCEEDED_WITH_GOOGLE',  payload: { user },});const initialState = {
  email: '',  password: '',  isLoading: false,  user: null,};const startAuthentication = state => ({ ...state, isLoading: true });const loginSucceeded = (state, { user }) => ({
  ...state,  isLoading: false,  user,});export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case loginWithEmailClicked().type: {
      return startAuthentication(state);    }
    case loginWithGoogleClicked().type: {
      return startAuthentication(state);    }
    case loginSucceededWithEmail().type: {
      return loginSucceeded(state, payload);    }
    case loginSucceededWithGoogle().type: {
      return loginSucceeded(state, payload);    }
    default: {
      return state;    }
  }
};
Enter fullscreen mode Exit fullscreen mode

There are two different actions depending on which strategy the user selected. Additionally, two actions are dispatched when either of the login strategies succeeds.

Since they trigger the same state update, the state updates can be abstracted into helper functions - startAuthentication and loginSucceeded - which are then used in the respective cases.

Here's how the sagas would use these actions.

import { call, put, takeLeading } from 'redux-saga/effects';import { loginWithEmail, loginWithGoogle } from './user-authentication-api';import {
  loginSucceededWithEmail,  loginSucceededWithGoogle,  loginWithEmailClicked,  loginWithGoogleClicked,} from './user-authentication-reducer';/*Email login*/function* handleLoginWithEmailClicked({ payload: { email, password } }) {
  const user = yield call(loginWithEmail, email, password);  yield put(loginSucceededWithEmail(user));}
export function* watchLoginWithEmailClicked() {
  yield takeLeading(loginWithEmailClicked().type, handleLoginWithEmailClicked);}
/*Google login*/function* handleLoginWithGoogleClicked() {
  const user = yield call(loginWithGoogle);  yield put(loginSucceededWithGoogle(user));}
export function* watchLoginWithGoogleClicked() {
  yield takeLeading(
    loginWithGoogleClicked().type,    handleLoginWithGoogleClicked,  );}
Enter fullscreen mode Exit fullscreen mode

You create two sagas that listen for the respective actions and dispatch the corresponding login success actions.

You also need to add a case to the toast reducer for each strategy because they should display different toast messages.

import {
  loginSucceededWithEmail,  loginSucceededWithGoogle,} from './user-authentication-reducer';export const showToast = message => ({ type: 'SHOW_TOAST', payload: message });const initialState = {
  message: '',};export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case showToast().type: {
      return { ...state, message: payload };    }
    case loginSucceededWithEmail().type: {
      return { ...state, message: 'Email login successful' };    }
    case loginSucceededWithGoogle().type: {
      return { ...state, message: 'Google login successful' };    }
    default: {
      return state;    }
  }
};
Enter fullscreen mode Exit fullscreen mode

In this example, you saw that the loginWithEmailClicked and loginWithGoogleClicked actions cause the same state update. And the loginSucceededWithEmail and loginSucceededWithGoogle actions cause the same state update in one reducer, but different state updates in another reducer.

Attaching type as a Static Property

The last point that confused people was encountering actions with a static type property, used like this:

// ... in some-reducer.jsconst reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case someAction.type: {
      // Handle action    }
  }
};// ... in some-saga.jsfunction* watchSomeAction() {
  yield takeEvery(someAction.type, handleSomeAction);}
Enter fullscreen mode Exit fullscreen mode

This pattern sometimes appears, especially with Redux Toolkit's createAction, which your learn in the third article of this series.

When creating actions manually, some developers like to attach the type as a static property to the action:

const someAction = payload => ({ type: someAction.type, payload });someAction.type = 'SOME_ACTION';const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case someAction.type: {
      return { ...state, message: payload };    }
    // ...  }
};function* watchSomeAction() {
  yield takeEvery(someAction.type, handleSomeAction);}
Enter fullscreen mode Exit fullscreen mode

This pattern has two main benefits:

  1. It avoids creating unnecessary action objects when calling the action to determine the type for the case or when hooking up sagas.
  2. If you have actions with transformations that require data, you can't call them without arguments, like we did above in cases and when hooking up sagas.
const explodeWithoutArgs = data => ({
  type: explodeWithoutArgs.type,  payload: data.toUpperCase(), // This will throw if data is undefined});explodeWithoutArgs.type = 'EXPLODE_WITHOUT_ARGS';const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case explodeWithoutArgs.type: {
      return { ...state, message: payload };    }
    case explodeWithoutArgs().type: { // This would crash ❌      return { ...state, message: payload };    }
    // ...  }
};
Enter fullscreen mode Exit fullscreen mode

Of course, you can avoid this, if you instead transform the data in the respective cases action handler instead.

This difference is small and stylistic. It comes down to preference.

Redux Toolkit

Everything you learned in this article also applies when creating slices with createSlice from Redux Toolkit (RTK).

My recent articles on Redux received some negative feedback because I'm showing Redux without RTK, and many people prefer using RTK. But I believe many fundamentals are easier to understand with vanilla Redux because learning RTK then becomes just a matter of learning the new API syntax.

Anyway, here's how the toast reducer would look with RTK. (Note: You might want to open this article in another tab and compare the two versions of the toast reducer side by side.)

import type { PayloadAction } from '@reduxjs/toolkit';import { createSlice } from '@reduxjs/toolkit';import {
  loginSucceededWithEmail,  loginSucceededWithGoogle,} from './user-authentication-reducer';export const {
  actions: { showToast },  reducer,} = createSlice({
  name: 'auth',  initialState: {
    message: '',  },  reducers: {
    showToast(state, action: PayloadAction<string>) {
      state.message = action.payload;    },  },  extraReducers: builder => {
    builder
      .addCase(loginSucceededWithEmail, state => {
        state.message = 'Email login successful';      })
      .addCase(loginSucceededWithGoogle, state => {
        state.message = 'Google login successful';      });  },});
Enter fullscreen mode Exit fullscreen mode

You can use the createSlice function to create your reducers.

You define the showToast action creator in the reducers object.

And you react to the loginSucceededWithEmail and loginSucceededWithGoogle actions in the extraReducers object.

The syntax of Redux Toolkit is explained in depth in the third article of this series on Redux, so go read that one next.

Top comments (0)