DEV Community

loading...
Cover image for Separating logic in your Redux Toolkit application

Separating logic in your Redux Toolkit application

Chinwike Maduabuchi
Frontend developer & Music junkie. Video games and books are my other happy places
Updated on ・8 min read

Redux Toolkit (which onwards, I will refer to as RTK) is a massive improvement to the Redux ecosystem. RTK changes the way we approach writing Redux logic and is well known for cutting off all the boilerplate code Redux requires.

I’ve enjoyed playing around with this library for the last couple of days, but recently, I found myself in an unpleasant situation. All my Redux logic, including asynchronous calls to APIs, was packed down into one slice file (more about slices in a bit).

Albeit this being the way RTK suggests we structure our slices, the file starts to become hard to navigate as the application grows and eventually becomes an eyesore to look at.

DISCLAIMER

This post isn’t an introductory guide on how to use RTK or Redux in general, however, I’ve done my bit to explain the little nuances that make RTK what it is.

A little understanding of state management in React is enough to help you wring some value from this post. You can always visit the docs to expand your knowledge.

SLICES

The term slice will be an unfamiliar word for the uninitiated so I’ll briefly explain what it is. In RTK, a slice is a function that holds the state eventually passed to your Redux store. In a slice, reducer functions used to manipulate state are defined and exported to be made accessible by any component in your app.

A slice contains the following data:

  • the name of the slice — so it can be referenced in the Redux store
  • the initialState of the reducer
  • reducer functions used to make changes to the state
  • an extraReducers argument responsible for responding to external requests (like fetchPosts below)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

const initialState = []

// async function
export const fetchPosts = createAsyncThunk(
  'counter/fetchPosts',
  async (amount) => {
    const response = await fetch('https://api.backend.com').then((res) => res.json())
    return response.data;
  }
);

// slice
export const postSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    addPost: (state, action) => {
      // some logic
    },
  },
})

export const { addPost } = postSlice.actions
export default postSlice.reducer
Enter fullscreen mode Exit fullscreen mode

Basic overview of a slice

In a nutshell, the slice file is the powerhouse of an RTK application. Let’s move on to create a new React application with RTK included by running the following command

    npx create-react-app my-app --template redux
Enter fullscreen mode Exit fullscreen mode

On opening your app in a code editor, you’ll notice that this template has a slightly different folder structure compared to that of create-react-app.

The difference is the new app folder which contains the Redux store and the features folder which holds all the features of the app.

Each subfolder in the features folder represents a specific functionality in the RTK application which houses the slice file, the component which makes use of the slice and any other files you may include here e.g. styling files.

This generated template also includes a sample counter component which is meant to show you the basics of setting up a functional Redux store with RTK and how to dispatch actions to this store from components.

Run npm start to preview this component.

With the way RTK has structured the app, each feature is completely isolated making it easy to locate newly added features in one directory.

THE PROBLEM

Let’s examine counterSlice.js

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { fetchCount } from './counterAPI';

const initialState = {
  value: 0,
  status: 'idle',
};
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async (amount) => {
    const response = await fetchCount(amount);
    return response.data;
  }
);

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  // The `reducers` field lets us define reducers and generate associated actions
  reducers: {
    increment: (state) => {
      // Redux Toolkit allows us to write "mutating" logic in reducers. It
      // doesn't actually mutate the state because it uses the Immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // Use the PayloadAction type to declare the contents of `action.payload`
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
  // The `extraReducers` field lets the slice handle actions defined elsewhere,
  // including actions generated by createAsyncThunk or in other slices.
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.status = 'idle';
        state.value += action.payload;
      });
  },
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state) => state.counter.value;

export default counterSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

As I previously mentioned, you will notice that all the logic needed to handle the state for the counter component is consolidated into this single file. The asynchronous calls made using createAsyncThunk, the createSlice function and the extraReducers property are all present.

As your application grows, you will continue to make more asynchronous requests to your backend API and in turn, have to handle all the possible states of that request to ensure that nothing unexpected breaks your application.

In RTK, the three possible states of a request are:

  • pending
  • fulfilled and
  • rejected

Keep in mind that handling one of these cases takes, at least, 3 lines of code. So that’s a minimum of 9 lines for one asynchronous request.

Imagine how difficult it would be to navigate the file when you have about 10+ asynchronous requests. It’s a nightmare I don’t even want to have.

THE SOLUTION

The best way to improve the readability of your slice files would be to delegate all your asynchronous requests to a separate file and import them into the slice file to handle each state of the request.

I like to name this file using ‘thunk’ as a suffix in the same way slice files use 'slice’ as their suffix.

To demonstrate this, I’ve added a new feature to the app which interacts with the GitHub API. Below is the current structure

features
|_counter
|_github
|_githubSlice.js
|_githubThunk.js

githubThunk.js

import { createAsyncThunk } from '@reduxjs/toolkit'

// API keys
let githubClientId = process.env.GITHUB_CLIENT_ID
let githubClientSecret = process.env.GITHUB_CLIENT_SECRET

export const searchUsers = createAsyncThunk(
  'github/searchUsers',
    const res = await fetch(`https://api.github.com/search/users?q=${text}&
      client_id=${githubClientId}&
      client_secret=${githubClientSecret}`).then((res) => res.json())
    return res.items
  }
)

export const getUser = createAsyncThunk('github/getUser', async (username) => {
  const res = await fetch(`https://api.github.com/users/${username}? 
      client_id=${githubClientId}&
      client-secret=${githubClientSecret}`).then((res) => res.json())
  return res
})

export const getUserRepos = createAsyncThunk(
  'github/getUserRepos',
  async (username) => {
    const res = await fetch(`https://api.github.com/users/${username}/repos?per_page=5&sort=created:asc&
    client_id=${githubClientId}&
    client-secret=${githubClientSecret}`).then((res) => res.json())
    return res
  }
)
Enter fullscreen mode Exit fullscreen mode

For more info on how to use createAsyncThunk, reference the docs.

These asynchronous requests are then imported into the slice file and handled in extraReducers

githubSlice.js

import { createSlice } from '@reduxjs/toolkit'
import { searchUsers, getUser, getUserRepos } from './githubThunk'

const initialState = {
  users: [],
  user: {},
  repos: [],
  loading: false,
}

export const githubSlice = createSlice({
  name: 'github',
  initialState,
  reducers: {
    clearUsers: (state) => {
      state.users = []
      state.loading = false
    },
  },
  extraReducers: {
    // searchUsers
    [searchUsers.pending]: (state) => {
      state.loading = true
    },
    [searchUsers.fulfilled]: (state, { payload }) => {
      state.users = payload
      state.loading = false
    },
    [searchUsers.rejected]: (state) => {
      state.loading = false
    },
    // getUser
    [getUser.pending]: (state) => {
      state.loading = true
    },
    [getUser.fulfilled]: (state, { payload }) => {
      state.user = payload
      state.loading = false
    },
    [getUser.rejected]: (state) => {
      state.loading = false
    },
    // getUserRepos
    [getUserRepos.pending]: (state) => {
      state.loading = true
    },
    [getUserRepos.fulfilled]: (state, { payload }) => {
      state.repos = payload
      state.loading = false
    },
    [getUserRepos.rejected]: (state) => {
      state.loading = false
    },
  },
})

export const { clearUsers } = githubSlice.actions
export default githubSlice.reducer
Enter fullscreen mode Exit fullscreen mode

I admit the extraReducers property still looks a bit clunky but we’re better off doing it this way. Fortunately, this is similar to the way logic is separated in a normal Redux application with the action and reducer folders.

ADDING SLICE TO THE STORE

Every slice you create must be added to your Redux store so you can gain access to its contents. You can achieve this by adding the github slice to App/store.js.

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
import githubReducer from './features/github/githubSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    github: githubReducer,
  },
})
Enter fullscreen mode Exit fullscreen mode

Another thing to take into consideration is how requests are handled in extraReducers. In the sample slice file, counterSlice, you’ll notice a different syntax is used to handle the requests.

In githubSlice, I’ve used the map-object notation in extraReducers to handle my requests mainly because this approach looks tidier and is easier to write.

The recommended way to handle requests is the builder callback as shown in the sample counterSlice.js file. This approach is recommended as it has better TypeScript support (and thus, IDE autocomplete even for JavaScript users). This builder notation is also the only way to add matcher reducers and default case reducers to your slice.

MUTABILITY AND IMMUTABILITY

At this point, you may have noticed the contrast in the way state is being modified in RTK compared to how it's done in a normal Redux app or React’s Context API.

RTK lets you write simpler immutable update logic using "mutating" syntax.

// RTK
state.users = payload

// Redux
return {
  ...state,
  users: [...state.users, action.payload]
}
Enter fullscreen mode Exit fullscreen mode

RTK doesn’t mutate the state because it uses the Immer library internally to ensure your state isn’t mutated. Immer detects changes to a “draft state” and produces a brand new immutable state based on your changes.

With this, we can avoid the traditional method of making a copy of the state first before modifying that copy to add new data. Learn more about writing immutable code with Immer here.

DISPATCHING ACTIONS IN COMPONENTS

With the aid of two important hooks; useSelector and useDispatch from another library called react-redux, you will be able to dispatch the actions you’ve created in your slice file from any component.

Install react-redux with this command

npm i react-redux
Enter fullscreen mode Exit fullscreen mode

Now you can make use of the useDispatch hook to dispatch actions to the store

Search.js

import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { searchUsers } from '../../redux/features/github/githubThunk'

const Search = () => {
  const dispatch = useDispatch()

  const [text, setText] = useState('')

  const onSubmit = (e) => {
    e.preventDefault()
    if(text !== '') {
      dispatch(searchUsers(text))
      setText('')
    }
  }

  const onChange = (e) => setText(e.target.value)

  return (
    <div>
      <form className='form' onSubmit={onSubmit}>
        <input
          type='text'
          name='text'
          placeholder='Search Users...'
          value={text}
          onChange={onChange}
        />
        <input
          type='submit'
          value='Search'
        />
      </form>
    </div>
  )
}

export default Search
Enter fullscreen mode Exit fullscreen mode

When the request is fulfilled, your Redux store gets populated with data

CONCLUSION

Redux Toolkit is undeniably an awesome library. With all the measures they took and how simple it is to use, it shows how focused it is on developer experience and I honestly believe RTK should be the only way Redux is written.

RTK also hasn’t stopped here. Their team has gone further to make RTK Query, a library built to facilitate caching and fetching data in Redux applications. It's only a matter of time before RTK becomes the status quo for writing Redux.

What do you think about this approach and RTK in general? I’d be happy to receive some feedback! 😄

Discussion (2)

Collapse
ivanjeremic profile image
Ivan Jeremic

Atomic state managers like Recoil or Jotai are the solution to this and the future for sure if you ask me.

Collapse
chinwike profile image
Chinwike Maduabuchi Author

Have heard about Recoil but not the other. I'd look more into that. Thanks!