DEV Community

Cover image for The Great Redux Toolkit Debate
Sam Magura
Sam Magura

Posted on

The Great Redux Toolkit Debate

An offhand comment I wrote one day while eating lunch sparked an unexpected and interesting debate with Mark Erikson, one of the maintainers of Redux.

Redux has long been the go-to library for managing global state in React applications. Redux Toolkit, which Mark helped create, is a relatively new library that aims to be the "official, opinionated, batteries-included toolset for efficient Redux development." This post will go into my thoughts on the benefits and potential drawbacks of Redux Toolkit.

Why Redux is Awesome

  1. It's unopinionated. Redux requires you to put your global state in a store, and to manage that state via reducers and actions. An action is a simple JavaScript object with a type property, and a reducer is a pure function that transforms the old state into the new state based on an action. Beyond this, everything else is up to you.
  2. It has a minimal API surface. Redux only has 5 top-level exports, and only one of those, createStore, is essential.
  3. It's extremely versatile. Do you want your store to contain only the ID of the current user? Or do you want your store to track the state of every entity, page, widget, and input in your massive enterprise app? Whatever your use case, Redux and its large ecosystem have you covered.

Why Redux is Hard

Redux is hard for the same reasons it is awesome.

  1. It's unopinionated. Redux doesn't tell you how to structure your application's state, reducers, or actions, so you have to make your own decisions.
  2. It has a minimal API surface. You'll quickly realize you need more than just createStore to create a useful application with Redux. A prime example of this is the need to fetch data from an API in response to an action.
  3. It's extremely versatile. There are so many different frontend architectures that are possible with Redux that it's easy to get lost. It took me a long time to figure out how Redux fit into the React applications I was building.

Redux Toolkit to the Rescue

Redux Toolkit aims to eliminate first two of these pain points by providing an opinionated, convenient, and beginner-friendly approach to Redux development. Its features include:

  • createAction — lets you define action creators, similar to typesafe-actions. I'm a TypeScript die-hard so type safety is non-negotiable. 😆
  • createReducer — allows you to write a reducer without a switch statement. Uses Immer under the hood. Immer is amazing and you should use it in your reducers even if you don't plan to use Redux Toolkit.
  • createSlice — a powerful helper that allows you to define both the reducer and actions for a slice of your state in one fell swoop.
  • createAsyncThunk — allows you to start an API call in response to an action and dispatch another action when the call completes.
  • createEntityAdapter — returns a set of prebuilt reducers and selector functions for performing CRUD on an entity.
  • RTK Query — a library for fetching and caching server state in your Redux store. Can be compared to React Query which aims to solve the same problems, but in a different way.

My Review of the Redux Toolkit (RTK) API

Overall Recommendation

  • If you're new to Redux, use RTK, but don't feel like you need to utilize all of its features. You can do a lot with just createAction and createReducer.
  • If you're already using Redux and Immer, there's no reason you have to switch to Redux Toolkit. Only use it if you agree with its opinionated approach.

createAction

Not a new idea but a useful one nonetheless. Currently, typesafe-actions seems to be more powerful than RTK in this area because the typesafe-actions getType function correctly types action.payload in switch reducers. The ActionType type helper is really nice too. I'd like to see RTK reach parity with typesafe-actions in this domain.

If you can figure out how to write a type safe switch reducer with RTK, let me know!

createReducer

As I said previously, Immer is really great. But Immer already works perfectly with switch reducers so I don't see a huge benefit to createReducer.

createSlice

I have a few concerns here. I like how in the traditional approach to Redux, you define your actions separately from your reducers. This separation of concerns allows you to lay out the operations your user can perform without getting bogged down in how those operations are implemented. createSlice eschews this separation and I'm not sure that's a step in the right direction.

createAsyncThunk

By including createAsyncThunk in Redux Toolkit, the Redux team has made thunks the officially-recommended side effect model for Redux. I like how Redux itself is unopinionated regarding side effects, so I have mixed feelings about the built-in support for thunks.

Of course, you can still use other side effect models like sagas and observables alongside Redux Toolkit. I'm a big fan of Redux Saga, which makes it straightforward to integrate Redux with your backend API while also enabling you to write incredibly powerful asynchronous flows. Sagas are written using generator functions and yield which does take some getting used to.

Mark tells me that sagas can be overkill for common use cases and thunks fit better here. I can see this point of view, but I still find sagas to be more intuitive and will be sticking with them.

createEntityAdapter

I'm concerned that createEntityAdapter could lead to designs that are overly CRUD-centric, favoring basic add, update, and remove actions over more meaningful, descriptive actions that are tailored to each entity. I'll admit that I don't fully understand the use case here. If createEntityAdapter saves you from writing tons of duplicate code, by all means use it.

RTK Query

RTK Query really is a separate library that happens to live in the same package as Redux Toolkit. I think it would be better as a separate package, but that's just me. Fortunately, RTK Query is exported from a separate entry point, so it will never be included in your bundle if you don't use it.

RTK Query seems complex to me, but my opinion might change if I tried it out. If you're looking for a data fetching solution, you should also consider React Query. I evaluated the similar SWR library but found it lacking some features that my team uses constantly.

Mark's Response to my Claim that RTK is Overly Opinionated

Read the full comment here! In summary:

As you can see, all of these APIs have a common theme:

  • These are things people were always doing with Redux, and have been taught in our docs
  • Because Redux didn't include anything built-in for this purpose, people were having to write this code by hand, or create their own abstractions
  • So we created standardized implementations of all these concepts that people could choose to use if they want to, so that people don't have to write any of this code by hand in the future.

What I Use in My Applications

My Last 4 React Web Apps

These are all medium-size single-page apps written entirely in React.

  • Redux is used for about 10% of the overall application state, with local component state making up the other 90%. We deliberately only use Redux for state that needs to stay in memory when navigating between screens, for example information about the current user.
  • We constructed our actions and reducers with typesafe-actions, Immer, and switch statements, whether using Redux or useReducer.
  • A simple custom-made useQuery hook is used to fetch data from the backend. This data ends up in the local state of our Page components.
  • There's a dash of Redux Saga to support login and persisting changes to complex order drafts that the user creates.

My React Native App

This app has to work offline so it made sense to put the majority of the app's state in Redux.

  • Redux Saga is responsible for all the interaction with the backend API. This worked out quite well. There's a pretty complex saga for sending queued operations to the backend when a user comes back from being offline.
  • The entire Redux store is persisted using redux-persist. This is still magic to me 😂.
  • Local component state is used for forms.

My Next React Web App

New projects are always exciting because they give you the chance to rethink your architecture and tech stack. Going forward, we will:

  • Stick with typesafe-actions and switch reducers. It was a close call between this and switching to Redux Toolkit's createAction and createReducer. Update: The RTK team has succeeded in convincing me to give createReducer and createSlice a shot!
  • Replace our homegrown useQuery with React Query. As a result, some state that we would have previously put in Redux will now be stored automatically in React Query's cache.
  • Continue to use Redux Saga in a few places.

Further Reading

Self-Promotion

Discussion (7)

Collapse
markerikson profile image
Mark Erikson

Good post! I'll add a couple more thoughts beyond what I said in the other thread:

createReducer/createAction

These were the first APIs we added to Redux Toolkit. createReducer really has two primary reasons for existing:

  • It originally only supported a "lookup table" parameter option for mapping action types to case reducer functions. This kind of utility was something that the community had reimplemented for themselves dozens of times (despite the basic approach being shown in the "Reducing Boilerplate" docs page since the beginning), so it was clear we needed to have some official form of that so everyone would stop recreating it.
  • By building in Immer, we guaranteed that it would "just work" correctly without anyone having to worry about it.

An additional reason is that for some reason a lot of people hate switch statements.

createAction was also something that existed in libraries like redux-actions. By writing it ourselves, we could ensure good TS typing, and build in useful bits like a "prepare callback" for defining the action payload.

Later on, we added a "builder callback" syntax for createReducer, which let us add more powerful "action matching" capabilities, as well as ensuring that the TS types for actions was correctly inferred in the provided reducer.

createSlice

I know what you mean by "defining actions first". But, what we've found is that probably 90%+ of all Redux actions are only ever handled by one slice reducer. Also, one of the most common parts of the "boilerplate" complaints was having to define action types and action creators, as well as "having" to split those across separate files like reducers.js, constants.js, and actions.js.

What really matters in Redux is the reducers. So, createSlice optimizes for that 90%+ use case - just write your case reducers, give them reasonable names, and createSlice automatically generates those action creators for you for free. The action types come along, but they're really just implementation details - the only time you need to worry about them is when you're reading the action history log in the Redux DevTools. So, way less code to write yourself, and only one file to worry about per feature.

This alone is one of the biggest savings in terms of code size and simplicity.

I'd really seriously suggest that you consider using createSlice instead of typesafe-actions + switch statements. All you have to do types-wise is supply the type of the slice state, and the type of each action payload, and all the action creators get typed correctly.

createAsyncThunk

I know you prefer sagas, but thunks have always been the most common side effects tool with Redux. As an example, see the stats on side effects libs from my "Redux Ecosystem" presentation in 2017. Declaring that we consider them to be the default was really just acknowledging what the community had already decided. And, as I said in the other comments, the fact that we provide built-in support for thunks in no way diminishes anyone's ability to use other options if they prefer.

createEntityAdapter

createEntityAdapter really just provides pre-built case reducers for you. It's up to you to decide what actions are correlated with that reducer logic, like in this example:

const booksSlice = createSlice({
  name: 'books',
  initialState: booksAdapter.getInitialState(),
  reducers: {
    // Can pass adapter functions directly as case reducers.  Because we're passing this
    // as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
    bookAdded: booksAdapter.addOne,
    booksReceived(state, action) {
      // Or, call them as "mutating" helpers in a case reducer
      booksAdapter.setAll(state, action.payload.books)
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

You can see some more examples of using this in action in Redux Essentials, Part 5: Performance and Normalizing Data.

RTK Query

Let me show a specific before and after comparison to illustrate the benefits. Here's the code from that same tutorial page for fetching a list of posts and managing its loading state:

// postsSlice.js
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
  const response = await client.get('/fakeApi/posts')
  return response.data
})

const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    // omit existing reducers here
  },
  extraReducers(builder) {
    builder
      .addCase(fetchPosts.pending, (state, action) => {
        state.status = 'loading'
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded'
        // Add any fetched posts to the array
        state.posts = state.posts.concat(action.payload)
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.error.message
      })
  }
})

// PostsList.js
export const PostsList = () => {
  const dispatch = useDispatch()
  const posts = useSelector(selectAllPosts)

  const postStatus = useSelector(state => state.posts.status)
  const error = useSelector(state => state.posts.error)

  useEffect(() => {
    if (postStatus === 'idle') {
      dispatch(fetchPosts())
    }
  }, [postStatus, dispatch])

  // render logic here
}
Enter fullscreen mode Exit fullscreen mode

Here's that same code with RTK Query:

// apiSlice.js
// Import the RTK Query methods from the React-specific entry point
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

// Define our single API slice object
export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
  endpoints: builder => ({
    getPosts: builder.query({
      query: () => '/posts'
    })
  })
})

export const { useGetPostsQuery } = apiSlice

// PostsList.js

export const PostsList = () => {
  const {
    data: posts,
    isLoading,
    isSuccess,
    isError,
    error
  } = useGetPostsQuery()

  // rendering logic here
}
Enter fullscreen mode Exit fullscreen mode

All we had to do was say "here's a URL endpoint", and we automatically got reducer logic and an auto-generated React hook that manages:

  • Initiating the request on mount
  • Making the actual request and parsing the data
  • Updating the loading state
  • Saving the data in cache
  • Selecting the data and the loading state
  • Re-rendering the component when that state changes

All of it code that you no longer have to write, and all of it correctly typed if you're using TypeScript.

API-wise, it's the exact same concept as React-Query, it's just that you define your list of known API endpoints ahead of time and get the query hooks generated for you, rather than passing fetching functions directly to the query hooks.

I will say that based on your use case of offline support, sagas are a reasonable choice here from what little I know of dealing with offline behavior.

Collapse
srmagura profile image
Sam Magura Author

Thanks for the positive response!

I also don't understand why people hate switch statements 😂. Regardless, I will reconsider using createSlice!

I like how simple your RTK Query example is, but I believe you've omitted connecting the API slice to the store. The connection to the store (especially the middleware) is what I found most intimidating.

I am curious — do createReducer or createSlice work with React's useReducer? It's nice to write reducers in the same way whether you're using Redux or useReducer. Thanks.

Collapse
markerikson profile image
Mark Erikson • Edited on

Hmm. Yeah, I left out the store setup piece, but that's trivial:

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import notificationsReducer from '../features/notifications/notificationsSlice'
import { apiSlice } from '../features/api/apiSlice'

export default configureStore({
  reducer: {
    posts: postsReducer,
    users: usersReducer,
    notifications: notificationsReducer,
    [apiSlice.reducerPath]: apiSlice.reducer
  },
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware().concat(apiSlice.middleware)
})
Enter fullscreen mode Exit fullscreen mode

It's really only two lines: adding the slice reducer, and adding the middleware.

I'm curious, what about that aspect feels "intimidating"? I would think that the middleware setup in particular is less confusing than having to use applyMiddleware + compose with vanilla Redux.

And yes, a reducer function is just a function, and you can absolutely use reducers from createReducer/createSlice with React's useReducer hook. I've done it frequently, including in apps that weren't using a Redux store at all, because I wanted to write some complex reducers with good TS typing.

Collapse
phryneas profile image
Lenz Weber

I like how simple your RTK Query example is, but I believe you've omitted connecting the API slice to the store. The connection to the store (especially the middleware) is what I found most intimidating.

This is something you will probably do once in your application and never touch again, ever. Our recommendation is to have only one api slice (there are still mechanisms in place to split it over multiple files if it gets too big) as RTK-Q actually benefits from having everything in one slice for stuff like "automatically refetch this query when I send off that mutation successfully".

Thread Thread
srmagura profile image
Sam Magura Author

Hey Lenz, I'll respond to both you and Mark in this comment.

The number of API slices

Seems I was confused here. I thought you would create a separate API slice for each entity in your application. The word "slice" is a bit confusing since usually you subdivide your Redux state into slices for each entity (like a users slice, a customers slice, an invoices slice, .etc).

Middleware is "intimidating"

I said the inclusion of middleware was intimidating because I feel like middlewares (as a general concept) are often black boxes that the average developer does not understand. Moreover, I thought you would be adding 30+ middlewares to your store, one for each API slice. I am much less concerned now that I know there would just be 1 middleware for the 1 API slice.

I appreciate the explanation of RTK-Q and I'm going to update the description of it in my post to be more positive.

You've also convinced me to give createReducer and createSlice a shot so I'll make that update too 🙂

Thread Thread
phryneas profile image
Lenz Weber

Well, a slice of a cake does not mean that there is only one type of fruit on it, right?
It really just means a "piece" or "subdivision" of your store - what's in there can vary wildly, from "by-feature" slices to "per-type" slices to "everything" slices :)

I get that middleware are intimidating - but on the other hand it's how we abstract logic. Having people install saga and add a RTKQ saga would probably be even more intimidating for most ;)

Anyway, it's great you are open to all this and I can just encourage you to also experiment a bit with it - it's pretty magical, especially how much everything cuts down on TypeScript types.

Collapse
guru0323 profile image
Guru

Hello