Transitioning to createSlice

I picked up on Redux and Redux-Saga a few years ago. I quickly mastered the ins and outs, and quirks using the state management library and fell in love with the ability to break complex logic down into simple processes that didn’t need to make use of overly complex promises with no end as to when they would complete.

It’s a simple concept. You have data. You have read-only access to information that is based off of that data using selectors. You dispatch an action/event stating what has happened, or an intent to do something along with information. Many listeners (reducers) respond (usually one), each doing their own little operation to change the underlying data. Other listeners (sagas) orchestrate the dispatch of other actions, often with a delayed response from a server or intensive work.

The amazing power came from Redux DevTools. This browser plugin would visualize what was going on. You could see actions as they were dispatched, and changes to state. It also had the capability to playback all of the state changes, and jump between states. Exporting the data as a JSON file resulted in exporting all of the actions without the underlaying data. This allowed you to share the history among developers tracking what lead up to a bug.

There are times to use local state via Reacts useState, and useContext to share state across all descendants. However, I find that a state management tool like redux is more beneficial when working with large sets of data and larger systems.

Microsoft Designer: Image Creator Prompt

A programmer writes code for a website written in TypeScript using React Redux. He is evaluating the style guide and making changes to his program to conform to the new changes in the style guide compared to what is suggested a few years ago. He is learning along the way, and trying to make sure that his code is easy to read, efficient, and modularized.

Simplifying Redux with Custom Async Actions and Lifecycle Support

As I transitioned my project to Redux Toolkit‘s createSlice function, I was faced with the challenge of managing complex async operations such as fetching data, handling drag-and-drop, and supporting abortable processes. In the past, I relied on a Redux Saga actions library to handle these common operations, but I needed more flexibility and a way to streamline the repetitive nature of additional boilerplate action creation.

In this blog post, I’ll walk you through how I simplified the process of managing these actions using createSlice and my custom action group generator, which now supports async lifecycle actions, drag-and-drop operations, and even abortable processes with progress reporting.

The Pre-createSlice Workflow

Before I started using createSlice, I would manually create action types and action creators for every async operation. To make this process more manageable, I used a Redux Saga actions library, and later developed my own custom utility, @codejamboree/action-builder to create groups of boilerplate actions. Here’s how it worked:

import actionBuilder from '@codejamboree/action-builder';
const build = actionBuilder('settings');
export const addItem = build.fetch('ADD');

// Typed payloads
type Item = { id: number, name: string, value: string };
type Error = { error: string};

export const addItem = build.fetch<
  Omit<Item, 'id'>,  // trigger payload
  void,                     // request payload
  Item,                     // success payload
  Error,                    // error payload
  void,                     // fulfill payload
>('ADD');

dispatch(addItem.trigger({
  name: 'title', 
  value: 'Example'
});
dispatch(addItem.request());
dispatch(addItem.success({
  id: 1, 
  name: 'title', 
  value: 'Example'
});
dispatch(addItem.error({
  error: new Error('bad stuff').message 
});
dispatch(addItem.fulfill());

Each action in the group corresponded to a stage in the async process: trigger to start the process, request for when the saga kicks off the request, success and failure to handle the results, and fulfill to wrap up the operation.

They could also be used to handle actions in reducers.

const addItemFulfill = produce((
  draft: ItemState,
  action: ReturnType<typeof addItem.fulfill>
) => {
  draft.isLoading = false;
});

handleActions({
  [addItem.FULFILL]: addItemFulfill
  },
  initialState
);

In addition, I could use them in Redux sagas as well.

export default takeEvery(
  addItem.TRIGGER,
  function* worker(action) {
    yield put(addItem.request());
    try {
      const results = yield call(
        apiPost, 'addItem', action.payload
      );
      yield put(addItem.success(results))
    } catch (error) {
      yield put(addItem.failure({ error: `${error}` }));
    } finally {
      yield put(addItem.fulfill());
    }
  }
);

Moving to createSlice with a Modular Approach

With createSlice, Redux Toolkit made managing actions and reducers much simpler, but it also required me to rethink my structure. I was already separating my logic into modules for better maintainability, but I needed to adapt that to fit createSlice while preserving the modular approach.

Here’s an example of how I now handle async actions using createSlice, combined with my custom utility for generating action types:

const settingsSlice = createSlice({
  name: 'settings đź› ',
  reducerPath: 'settings',
  initialState,
  reducers: {
    load,
    loadRequest,
    loadSuccess,
    loadFailure,
    loadFulfill
  },
  selectors: {
    selectHasError,
    selectCanLoad,
    selectIsLoading,
    selectItems
  },
  extraReducers: (builder) => {
    builder.addCase(authentication.logout, logout)
  }
});
export default settingsSlice;

The name of the reducers are the actions, and are exposed via the exported settingSlice. The action type includes the slice name and reducer key. So reducers.loadRequest has an action type of “settings đź› /loadRequest”. The reducerPath is optional, as it will use the slice name. However, I like to use emoji in my action types so that I can visually identify different slices easier when inspecting the playback in Redux DevTools.

The selectors are now included as well – but they don’t have access to global state. Instead, they only have access to the associated slice of state.

I also have the ability to listen to actions from other slices via extraReducers. The setup is a bit different, but reducers only have access to the current slices state. The way that I usually approach redux slices, my reducers never needed anything outside of their own slice.

What’s nice about this is that immer is also used under the hood. Rather than using the produce method to ensure that the original state is not mutated, I can create a simple function to work with the state directly, as its already setup as a proxy representing the immutable object.

Immer objects are a pain to inspect when debugging because they are just proxies rather than a normal object. A current utility function is provided to translate it back to an object that can be logged to the console.

import { PayloadAction, current } from "@reduxjs/toolkit";
import { Hierarchical } from "../../../types/Hierarchical";
import { Search } from "../../../types/Search";
import { Subset } from "../../../types/Subset";
import { LoadState } from "../../../types/LoadState";

export const load = (
  state: {
    loadState: LoadState,
    error?: string
  },
  _action: PayloadAction<Hierarchical & Partial<Search & Subset>>
) => {
  if (state.loadState === 'loading') return;
  delete state.error;
  console.log(state);
   // Proxy(Object) {type_: 0, scope_: {…}, modified_: true, finalized_...
  console.log(current(state));
  // {depthIds: Array(1), allIds: Array(0), byId: {…}, childIds
};

Selectors have also changed. I often use the reselect library to create a selector composed of multiple selectors like so…

import { createSelector } from "reselect";

const createSlice = (
  { settings = createState() } = {}
): AuthenticationState => settings;

export const selectToken = createSelector(
  selectSlice, 
  ({ token }) => token
);
export const selectIsAuthenticated = createSelector(
  selectSlice,
  ({ authenticated }) => authenticated ?? false
);
const selectOtpRequired = createSelector(
  selectSlice,
  ({otpRequired}) => otpRequired;
);
export const selectCanBypass2FA = createSelector(
  selectIsAuthenticated,
  selectOtpRequired,
  (isAuthenticated, otpRequired) => {
    if (otpRequired === undefined) return false;
    return isAuthenticated && !otpRequired;
  }
);

You’ll noticed that for every slice, the first selector I write is selectSlice to get the state for the local slice, and every selector within the slice depends on it directly or indirectly. This is now baked in. Rather than being passed the global state, the selector now receives the slice state. This means that modularized selectors within their own files can be reused in other slices, since they don’t depend on the selectSlice to pull it from the global state.

This is great, because I’m often using the same object structure in each slices state – allIds, byId, childIds, error, loadingState, etc. In addition, much of my data has consistent naming conventions as well – id, name, parentId, order, etc. Using generics, I can keep the items typecasted properly while only working with specific fields that I need.

In addition, I no longer need to depend on the reselect library to coalesce multiple selectors into one. The redux toolkit already has them available.

import { createSelector } from "@reduxjs/toolkit";

export const selectToken = (
  state: {
    token: string
  }
) => state.token;

export const selectIsAuthenticated = (
  state: {
    authenticated: boolean
  }
) => state.authenticated ?? false;

const selectOtpRequired = (
  state: {
    otpRequired?: boolean
  }
) => state.otpRequired;

export const selectCanBypass2FA = createSelector(
  selectIsAuthenticated,
  selectOtpRequired
  (isAuthenticated, otpRequired) => {
    if(otpRequired === undefined) return false;
    return isAuthenticated && !otpRequired
  }
);

What you’ll notice, is that I have a lot of Type information since I no longer have access to the initial state that provided all of that information. However, the extra “fluff” is trumped by the fact that many of my common selectors share the same logic and can be reused among multiple slices. Specifically, I’m referencing the data itself…

export const selectById = <T extends Identified>(
  state: {
    byId: ById<T>
  }
) => state.byId;

export const selectChildIds = (
  state: {
    childIds: ById<number[]>
  }
) => state.childIds;

export const selectMovableIds = (
  state: {
    movableIds: ById<number[]>
  }
) => state.movableIds;

export const selectDepthIds = (
  state: {
    depthIds: (number | null | undefined)[]
  }
) => state.depthIds;

export const selectLevel1ParentId = createDraftSafeSelector(
  selectDepthIds,
  (depthIds) => depthIds[0]
);

export const selectLevel1Ids = createDraftSafeSelector(
  selectMovableIds,
  selectChildIds,
  selectLevel1ParentId,
  (movableIds, childIds, parentId) => {
    if (parentId === undefined) return [];
    const key = idKey(parentId);
    return movableIds[key] ?? childIds[key] ?? [];
  }
)

export const selectLevel1Items = createDraftSafeSelector(
  selectLevel1Ids,
  selectById,
  selectSavingIds,
  (ids, byId, savingIds) => ids
    .map(asItem(byId))
    .map(withBusyState(savingIds))
);

As separate files, you can reuse these in multiple slices so long as their state conforms to the expected structure.

Handling Complex Async Logic with Redux Saga

While createSlice helped me organize my reducers, selectors, and actions, I still relied on Redux Saga to manage complex async operations, such as fetching data, handling drag-and-drop events, and aborting processes.

Redux has some pretty strict rules in place, and one of them is that you can’t have any “side effects”. A reducer can’t use selectors, dispatch actions. Selectors can’t mutate the state or dispatch actions. This is where Redux Sagas come into play. They listen for actions, can get information from selectors, and dispatch actions. Since they are done in generator functions, they can pause their operation while the state changes from those actions using the yield operator. The saga later picks up where it left off, and can use a selector again to access the changed information and continue its work.

The modular approach that I previously adopted was easily changed over to continue to handle these actions in a consistent manner, no matter what the operation was.

Here’s an example saga for handling the fetch process:

import { takeEvery, call, put, select } from "redux-saga/effects";
import post from "../../../api/post";
import typesSlice from '../typesSlice';

const {
  actions: {
    load,
    loadRequest,
    loadSuccess,
    loadFailure,
    loadFulfill
  },
  selectors: {
    selectIsLoading
  }
} = typesSlice;

type Request = Parameters<typeof loadRequest>[0];
type Response = Parameters<typeof loadSuccess>[0];
type IsLoading = ReturnType<typeof selectIsLoading>;
type LoadAction = ReturnType<typeof load>;

export default takeEvery(load.type, worker);

function* worker(action: LoadAction) {

  const isLoading: IsLoading = yield select(selectIsLoading);

  if (isLoading) return;

  const {
    limit = 10,
    offset = 0,
    parentId = null,
    search = ''
  } = action.payload ?? {};

  const loading: Request = { limit, offset, parentId, search };

  yield put(loadRequest(loading));
  try {
    const results: Response = yield call(post, 'types/search', loading);
    yield put(loadSuccess(results))
  } catch (error) {
    if (error instanceof Error)
      yield put(loadFailure({ error: error.message }));
    else
      yield put(loadFailure({ error: `${error}` }));
  } finally {
    yield put(loadFulfill());
  }
}

Rather than importing a bunch of files containing each selector and action used, only the slice is imported. A downside may be that an unused selector isn’t removed via tree shaking when the code is bundled with Webpack, Vite, or Rollup. However, I prefer the consistency of grouping everything together, so long as I don’t have too many of them to manage.

So how is this wired up? Something interesting is taking place now. In the past, the root reducer would dictate the name of the slices. You could do some fancy footwork to allow the slices to provide that info, but now its baked in.

const reducers = {
  [typesSlice.reducerPath]: typesSlice.reducer, // new way
  authentication: authenticationReducer, // old way
};

const sliceReducers = combineReducers(reducers);

The slice is able to dictate what its name should be. If you are creating an initial root state object, you can access the initial state and set it up accordingly.

const createRootState = () => ({
  [typesSlice.reducerPath]: typesSlice.getInitialState(), // new way
  authentication: createAuthenticationState() // old way
});

Gotchas!

This is all great, but I did run into a few problems.

Empty noop reducers. In some cased, my saga depended on an action which there was no reducer. This is usually the trigger action of a fetch lifecycle. I would end up creating noop (No Opperation) function just so that an action was created. Furthermore, the payload needed to be typed. I was able to make it a generic noopAction to reduce the need to create different noop actions that only changed the payload type.

export const noopAction = <T>(
  _state: object,
  _action: PayloadAction<T>
) => { };

createSlice({
  reducers: {
    reorderTrigger: noopAction<Ordered[]>,
    reorderRequest,
    reorderSuccess,
    reorderFailure,
    reorderFulfill
  }
});

Is this correct? I’m uncertain. I could create my own action separately via createAction – but then it would be outside the context of the slice that was created, and there is potential for inconsistencies with its name vs the slice name. The action belongs with its fetch lifecycle siblings. I’m still thinking through it, as the current method causes the reducer to execute when it is never expected to have any effect.

memoize createSelector: I ran into a few problems with selectors at first. Part of the fun was realizing that I didn’t ned a helper utility to createSelector for half of them once selectSlice was no longer needed. I was selecting values strait from the state object. Some of them still needed to make use of other selectors. The important part here is that you want to take advantage of memoized data so that your selectors do not trigger unnecessary renders. Redux Toolkit now offers several ways to create selectors, and I stumbled into the reason why. Once you start mapping arrays, constructing objects with spread operators, or doing something that causes a different memory address to an object to be returned, but with the same exact data structure as the previous memory address, you start to see warnings in the console. Instead of createSelector, you need to use createDraftSafeSelector.

Parameterized Selectors. This is a long standing problem I’ve had for years. I usually work out a solution to avoid it, but it always comes up. I have a list of data – how do I display that list, while letting each component use a selector to get data specific to the id that I passed to the component? Here is an example:

export const selectGetData = state => id => state.byId[id];

export const Item = ({ id }) => {
  const getData = useSelector(selectGetData);
  const { name } = getData(id);
  return <li>{name}</li>
}

I’m unable to find any solid information if selectors are allowed to return functions, or if this is an anti-pattern to be avoided. With the prior code, our call to “getData” is executed with each render. We would need to memoize the call so it only fires when id or the selectors response changes.

export const Item = ({ id }) => {
  const getData = useSelector(selectGetData);
  const { name } = useMemo(() => getData(id), [id, getData]);
  return <li>{name}</li>
}

Personally, it feels a bit messy. Part of the problem is when any underlying data changes on state.byId, all references to selectGetData need to re-render, even if the item we are interested in hasn’t changed.

The fix seems to be to pass the item data to the component as a prop, so it doesn’t render if item doesn’t change. The code is much simpler to read as well, but this is only an example.

export const Item = ({ name }) => <li>{name}</li>;

I’m also looking into RTK Query. I started looking into it before I went deep into the createSlice routine and converted everything over. RTK looks like it may be the answer to consolidating my fetch actions. I don’t like how it represents all API calls on one server. I have many endpoints, and that file is going to be large. The tagging is interesting, but it seems like RTK manages a store of its own, within a store, and is primarily meant to be called by the front-end components. It reminds me of my experience with React Query was similar. RTK seems like it pollutes its own state with a lot of excessive data since it has to maintain state for all requests, caching, and tagging.

The problem is that I need to be able to do a few operations on the underlying data in my reducers – specifically with drag & drop operations, and changing sort orders and parent objects. I’ve only just begun to look at it, so I’m still a bit new to what it’s capable of. Perhaps I can hook into it via externalReducers, and trigger some of its request via sagas.

createAsyncThunk – I’ve just briefly looked at this. I’m uncertain if it’s Redux’s version of what Redux-Saga offers. However, it uses async functions instead of function generators. I recall it took a while to wrap my head around how to use sagas and unit test them. I don’t know if async functions it have the same kind of performance benefits where it can dispatch actions, pause its execution, and pick up where it left off, with access to the changed state via selectors. If it’s something more in-line with the redux style guide, or best practices, I believe I would prefer making the switch if it’s a more optimal approach. I need to experiment and see if its worth using.

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading