Redux Optimizations

React Redux, the Redux Toolkit, and RTK Query have all made many improvements over time. To me, the three libraries are essentially “Redux”. These days, you usually don’t need to step out of their ecosystem and use third party libraries to perform asynchronous operations, code splitting, boilerplate actions, and fetching data.

This post is basically how I’ve setup my project with Redux and code-splitting. It may not be perfect or with the best practices in mind. I’m often changing things around. This is especially the case as I’ve been learning the new practices within the Redux ecosystem lately that have changed quite a bit from when I first learned about Redux years ago.

The primary goal of the post is to show you the main parts of a Redux project loading reducers and middleware on the fly with support for hot module reloading, using RTK Query, caching, and code splitting. It is assumed that you already know the basics of using selectors, dispatching actions, creating actions and reducers.

Initial Load

Take note that with code splitting, I can minimize the time for the application to start loading. I take care to display a message if the main components of the app (template, router, store, etc) takes longer than 2 seconds to load. There is a general consensus that clients tend not to stick around if a page takes 3-5 seconds to load. This threshold may increase for clients on slow and unstable connections. The most significant hurdle during testing is the react-dom/client, in which not much can be done to reduce it’s 1MB file size until you go to production with bundlers doing tree shaking, minification, and compression. Testing is usually done by throttling connection speeds between 3G and Fast 4G (500 kbps to 10 Mbps).

@../index.html
<!doctype html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <title>Inventory System</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>

<body>
  <div id="root"></div>
  <script type="module" defer src="/src/main.tsx"></script>
  <script>
    document.getElementById('root')
      .setAttribute('data-ts', Date.now().toString());

    document.getElementById('root')
      .setAttribute(
        'data-timeout',
        setTimeout(function () {
          var element = document.getElementById('root');
          if (!element) return;
          element.removeAttribute('data-timeout');
          if (!element.hasChildNodes()) {
            element.innerText = 'Please wait...';
          }
        }, 2000).toString()
      );
  </script>
</body>

</html>
@/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import ErrorBoundary from '@/components/ErrorBoundary';
import { DelayedSuspense } from '@/components/DelayedSuspense';
import App from '@/app';
import { ConnectionQualityProvider } from '@/components/ConnectionQualityProvider';

const root = document.getElementById('root');

if (root === null) {

  console.error('Root element not found');

} else {

  if (root.hasAttribute('data-timeout')) {
    const timeoutId = root.getAttribute('data-timeout')!;
    clearTimeout(timeoutId);
    root.removeAttribute('data-timeout');
  }
  let initialized = new Date();
  if (root.hasAttribute('data-ts')) {
    initialized = new Date(parseInt(root.getAttribute('data-ts')!, 10));
  }

  createRoot(root)
    .render(
      <StrictMode>
        <ConnectionQualityProvider initialized={initialized}>
          <DelayedSuspense fallback="Loading Error Handler">
            <ErrorBoundary fallback="Something went wrong">
              <DelayedSuspense fallback="Loading App">
                <App />
              </DelayedSuspense>
            </ErrorBoundary>
          </DelayedSuspense>
        </ConnectionQualityProvider>
      </StrictMode >
    );
}

Connection Quality Provider

The sole purpose of this provider is to detect if connections appear to be slow, and to share that information with components that change their behavior to compensate. I assume that a connection is slow, if more than 2 seconds have passed since the page has first loaded.

@/components/ConnectionQualityProvider.tsx
import { FC, ReactNode, createContext, useContext, useEffect, useState } from "react";

interface Props {
  children: ReactNode,
  initialized: Date
};

const TIMEOUT_MS = 2000;

const context = createContext({
  isSlow: false,
  setIsSlow: (_isSlow: boolean) => { },
  quickLoadExpiration: new Date(Date.now() + TIMEOUT_MS)
});

export const useConnectionQuality = () => useContext(context);

export const ConnectionQualityProvider: FC<Props> =
  ({
    children,
    initialized
  }) => {
    const [isSlow, setIsSlow] = useState(false);
    const [quickLoadExpiration, setQuickLoadExpiration] = useState(
      new Date(Date.now() + TIMEOUT_MS)
    );
    useEffect(() => {
      const quickLoadExpiration = new Date(initialized.getTime() + TIMEOUT_MS);
      setQuickLoadExpiration(quickLoadExpiration);
      setIsSlow(quickLoadExpiration < new Date());
    }, [initialized]);

    return <context.Provider value={{ isSlow, setIsSlow, quickLoadExpiration }}>{children}</context.Provider>;
  };

Delayed Suspense

The Delayed Suspense is a wrapper on the Suspense component, and is meant only for loading initial components. Third party user controls and localization is excluded for the sake of keeping the file size small. Similar to the delayed message as the initial script is loading, the Delayed Suspense component displays a message if the connection takes too long. It’s able to do this based on the information from the Connection Quality provider. For slow connections, the client may have the following experience:

  • Blank screen for 2 seconds
  • “Please wait…”
  • “Loading Error Handler”
  • “Loading App”

However, some connections may have cached some data already, or a little bit faster, and end up with the following experience:

  • Blank screen for 2 seconds
  • (skipped “Please wait…”)
  • “Loading Error Handler”
  • “Loading App”

Again, the primary goal is to prevent people with faster connections from seeing anything at all, while slower connections see a transition of different messages once it’s been determined that resources are taking longer to download.

@/components/DelayedSuspense.tsx
import { FC, ReactNode, Suspense, useEffect, useState } from "react";
import { useConnectionQuality } from "./ConnectionQualityProvider";
interface DelayedSuspenseProps {
  fallback: string,
  children: ReactNode
}
export const DelayedSuspense: FC<DelayedSuspenseProps> =
  ({ fallback, children }) => {
    const { isSlow } = useConnectionQuality();
    return (
      <Suspense fallback={isSlow ? fallback : <DelayedStatus message={fallback} />}>
        {children}
      </Suspense>
    );
  }

const DelayedStatus: FC<{ message: string }> = ({ message }) => {
  const { isSlow, setIsSlow, quickLoadExpiration } = useConnectionQuality();
  const [visible, setVisible] = useState(isSlow);
  useEffect(() => {
    if (visible) return;
    let timeoutId: any = setTimeout(() => {
      setVisible(true);
      setIsSlow(true);
      timeoutId = undefined;
    }, Math.max(0, quickLoadExpiration.getTime() - Date.now()));
    return () => {
      if (timeoutId) clearTimeout(timeoutId);
    }
  }, []);

  return visible ? message : null;
}

Error Boundary

Keep it simple and lightweight. Don’t risk creating errors when displaying an error. Stick with native html elements to reduce code needed to render errors. It has an event in which you can report errors outside of it, and the default fallback can be used to display a custom message.

Error Boundaries must be class components. React doesn’t have any hooks to catch errors within functional components. Specifically, the methods for componentDidCatch and getDerivedStateFromError are not exposed as hooks.

@/components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from "react";

interface RenderError {
  (error?: Error, errorInfo?: ErrorInfo): ReactNode
}
interface ErrorProps {
  children: ReactNode;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
  fallback?: ReactNode | RenderError
}

interface ErrorState {
  failed: boolean;
  error?: Error;
  errorInfo?: ErrorInfo;
}

class ErrorBoundary extends Component<ErrorProps, ErrorState> {
  constructor(props: ErrorProps) {
    super(props);
    this.state = { failed: false };
  }
  static getDerivedStateFromError(error: Error): ErrorState {
    return { failed: true, error };
  }
  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    const { onError } = this.props;
    this.setState((prev) => ({
      ...prev,
      error: error ?? prev.error,
      errorInfo,
    }));
    console.error('Error');
    if (error) {
      console.error(error.message);
      console.debug(error.stack);
    }
    if (errorInfo) {
      console.debug(errorInfo.componentStack);
    }
    if (typeof onError !== "function") return;
    try {
      onError(error, errorInfo);
    } catch (e) {
      console.error(`Error calling onError`, e);
    }
  }
  public render() {
    const {
      fallback = "[ERROR]",
      children,
    } = this.props;

    const { failed, error, errorInfo } = this.state;

    let notice: ReactNode = null;

    if (failed) {
      if (typeof fallback === 'function') {
        try {
          notice = fallback(error, errorInfo);
        } catch (e) {
          console.error(`ErrorBoundary fallback`, e);
          notice = "[ERROR]";
        }
      } else {
        notice = fallback;
      }
    }
    return failed ? notice : children;
  }
}

export default ErrorBoundary;

Code Splitting Provider

In the past, my stores were a bit large, and code splitting it had a benefit if loading it separately. The store itself is now fairly lightweight, but this may be where you are initially loading the redux libraries based on how your bundler splits the code.

@/components/StateProvider/index.ts
import { lazy } from "react";
export default lazy(() => import("./StateProvider"));
@/components/StateProvider/StateProvider.tsx
import { FC, ReactElement } from "react";
import { Provider } from "react-redux";

const StateProvider: FC<{
  store: any;
  children: ReactElement;
}> = ({ store, children }) => (
  <Provider store={store}>{children}</Provider>
);

export default StateProvider;

App Store with Dynamic Middleware

The app store is a bit different than I normally set it up in past projects. It’s evolved over time to handle unique situations. The primary situation that I’m in at the moment is that I need to capability to code split various slices or features. In the past, you had to jump through hoops with third-party libraries to get this to work. With the latest updates to the Redux libraries, they’ve made it possible to swap your reducers on the fly and add middleware.

With code splitting, you’ll see that the only static slice that I have is for “root”. This is a junk slice that was added, not not used. Redux requires at least one slice when combining reducers, so adding an empty root reducer slice is how I get around it. All other slices, including the API are loaded on-demand as needed.

One last note is that you can see that I’m maintaining lists of reducers and middleware. This will be used to replace reducers and middleware.

@/app/store.ts
import {
  configureStore,
  createDynamicMiddleware,
  combineReducers,
  type Reducer
} from "@reduxjs/toolkit";

export const dynamicReducers: Record<string, Reducer> = {};
export const dynamicMiddleware = createDynamicMiddleware();

export const createRootReducer = () =>
  combineReducers({
    root: (state: any = {}) => state,
    ...dynamicReducers
  });

const createAppStore = () => configureStore({
  reducer: createRootReducer(),
  middleware: getDefaultMiddleware => getDefaultMiddleware()
    .prepend(dynamicMiddleware.middleware)
});

export const store = createAppStore();
Add / Replace Reducers & Middleware

The logic to inject middleware and reducers is in a separate file. Replacing reducers are fairly simple. Always has been. The tough part is the middleware. The recent addition of createDynamicMiddleware brought us 99% closer to the solution.

Although it provides a way to add middleware to your store, it did not provide a way to remove or replace it, other than rebuilding the store itself. In a production environment, this would not matter since middleware isn’t swapped out. However, a development environment has hod module replacement (HMR), allowing middleware to be appended each time a change is made to the file. I can’t remove the middleware itself, as there is a chain of responsibility in the middleware pattern. Removing the middleware would be breaking that chain. However, I can wrap it to bypass the original middleware and continue the chain if a condition isn’t met.

In order to pull this off, I maintain a list of the latest versions for the injected middleware. Each time middleware is injected, the version for the associated slice key that it’s associated with is incremented. In turn, I have a conditional middleware wrapper that evaluates if the middleware meets a condition before walking through each part.

@/app/injectReducer.ts
import { Middleware, Reducer } from "@reduxjs/toolkit";
import { createRootReducer, dynamicMiddleware, dynamicReducers, store } from "./store";

const versions: Record<string, number> = {};

export const injectReducer = (
  key: string, reducer: Reducer, middleware?: Middleware) => {
  dynamicReducers[key] = reducer;
  store.replaceReducer(createRootReducer());
  if (!middleware) {
    if (key in versions) {
      // deactivate
      versions[key]++;
    }
    return;
  }

  const version = (versions[key] ?? 0) + 1;
  versions[key] = version;
  const isLatest = () => versions[key] === version;
  dynamicMiddleware.addMiddleware(
    conditionalMiddleware(middleware, isLatest)
  );
}

const conditionalMiddleware = (
  middleware: Middleware,
  meetsCondition: () => boolean
): Middleware =>
  api => {
    if (!meetsCondition()) {
      return next => action => next(action);
    }
    const mApi = middleware(api);
    return next => {
      if (!meetsCondition()) {
        return action => next(action);
      }
      const mNext = mApi(next);
      return action => meetsCondition() ?
        mNext(action) :
        next(action)
    }
  }

One thing of note is that due to the curling nature of the high order function, you need to repeatedly evaluate the condition as each of the functions in the chain are called. Once a condition isn’t met, I divert to using the arguments passed to the function, rather than the middleware itself, effectively bypassing remaining operations for that version of the middleware in the chain.

Feature Slices

When I first worked with Redux, the store was separate from user controls. However, you were always jumping back and forth in your project navigating between your user controls and slices, as they were tightly coupled with each other. The Redux style guide has been updated in recent years to reflect that user controls should be stored alongside your slices, and that the folder structure sits under a parent folder named features.

This new setup, along with code splitting to inject reducers and middleware on the fly brings in the opportunity to split off features into separate projects outside of your application. A feature view an image library or enter an address with estimated shipping costs is able to be included in multiple projects.

@/features/settings/settingsSlice.ts
import emoji from '@lewismoten/emoji';
import { createAppSlice } from "@/app/createAppSlice";
import { injectReducer } from '@/app/injectReducer';

const name = `settings ${emoji.gear}` as const;

export const settingsSlice = createAppSlice({
  name,
  reducerPath: name,
  initialState: {
    isEditingSite: false
  },
  reducers: create => ({
    toggleEditSite: create.reducer((
      state
    ) => {
      state.isEditingSite = !state.isEditingSite;
    }),
  }),
  selectors: {
    selectIsEditingSite: state => state.isEditingSite
  }
});

export const {
  toggleEditSite
} = settingsSlice.actions;

injectReducer(
  settingsSlice.reducerPath,
  settingsSlice.reducer
);

You’ll notice at the bottom, injectReducer is called with both the reducer path, and the reducer itself. This particular slice has no middleware, but the reducer will still be replaced as the file is edited.

In the past, it was recommended that your reducer file for a given slice, was in the index file, and returned as the default export. Ducks patterns continued this default export for the reducer, but also introduced the standard to export all actions as named exports as well. The createSlice function was later introduced to help enforce the ducks pattern by keeping reducers, selectors, initial state, and actions together in one file – but you could still modularize everything into separate files if you wanted, but at a cost of losing the benefits of typescript inference. With createSlice, it was suggested to export the reducer by default.

export default settingsSlice.reduce

This tended to add a bit of an inconvenience when programming though. As you start typing out the names of exported variables, it was a hit or miss (usually a miss) that the IDE would know that you wanted the named export rather than the default export of the file it was contained in. Only one place in your project needed access to the reducer itself, where everywhere else needed the slice to gain access to the selectors or actions that you had forgotten to export, or wanted to deconstruct from the slice itself. What’s worse, is that the dropdown for the suggested file to add did not give an indication if it was importing the named export, or the default export.

The first suggestion here is the default export, and the second is the named export.

import { settingsSlice } from './settingsSlice';
import settingsSlice from './settingsSlice'; // reducer

In the end, the name of the file does not reflect the content of the default export. I no longer export the reducers as default due to this paradigm shift. Reducers, once the only logic in state/{name/index.js, is now a small part of feature/name/nameSlice.js. In fact, importing from /state/index.js was supposed to be the root reducer. The application store that uses it has been moved completely outside of the folders containing slices and is now located at @/app/store

RTK Query

This slice is fairly unique compared to the rest. The most difficult part in decoupling RTK Query from the application store was it’s dependency on the auth slice. When a query fails with a 401 status code, I fetch a refresh token from the auth slice using its selectors, and then notify it that a new authorization token has been issued.

Initially I ran into cyclic redundancy issues as the authSlice and apiSlice were dependent on each other. The end result was that I needed to pull certain portions out of the authSlice into a separate file that the api could use. For the most part, this brings us back to creating actions and selectors the original way outside of createSlice.

Similar to how combining reducers requires at least one slice for the application store, RTK query requires an object, but doesn’t require it to be populated.

@/features/auth/shared.ts
import { createAction, createSelector } from '@reduxjs/toolkit';
import emoji from '@lewismoten/emoji';

export const name = `auth ${emoji.lockedWithKey}` as const;
export const reducerPath = name;

export type AuthState = {
  authorizationToken?: string,
  refreshToken?: string
}
const selectSlice = (state: unknown): AuthState => {
  if (
    typeof state !== 'object' ||
    state === null ||
    !(reducerPath in state)
  ) return {};
  return state[reducerPath] as AuthState;
}
export const selectToken = createSelector(
  selectSlice,
  state => state.authorizationToken
);

export const selectRefreshToken = createSelector(
  selectSlice,
  state => state.refreshToken
)

export const selectHasRefreshToken = createSelector(
  selectRefreshToken,
  refreshToken => !!refreshToken && refreshToken !== ''
);

const authorizationTokenIssuedType = `${name}/authorizationTokenIssued` as const;
export const authorizationTokenIssued = createAction<
  string,
  typeof authorizationTokenIssuedType
>(authorizationTokenIssuedType);

const logoutType = `${name}/logout` as const;
export const logout = createAction<
  void,
  typeof logoutType
>(logoutType);
@/features/api/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import emoji from '@lewismoten/emoji';
import * as auth from '@/features/auth/shared';
import { injectReducer } from '@/app/injectReducer';

import { logError } from './logError';
import { reauthorize } from './reauthorize';

const unauthorized = 401;
const baseUrl = 'https://localhost/api';

export const apiSlice = createApi({
  reducerPath: `api ${emoji.wireless}` as const,
  baseQuery: async (args, api, extraOptions) => {
    const baseQuery = fetchBaseQuery({
      baseUrl,
      prepareHeaders(headers, api) {
        headers.set('Content-Type', 'application/json');
        const token = auth.selectToken(api.getState());
        if (token && token !== '') {
          headers.append("Authorization", `Bearer ${token}`);
        }
        return headers;
      }
    });
    let result = await baseQuery(args, api, extraOptions);

    if (result.error) {
      logError(result.error);
      if (result.error.status === unauthorized) {
        return await reauthorize(result, baseQuery, args, api, extraOptions);
      }
    }
    return result;
  },
  endpoints: _build => ({})
});

injectReducer(
  apiSlice.reducerPath,
  apiSlice.reducer,
  apiSlice.middleware
);

Now the apiSlice has a few “Gotcha’s”. Normally, the api sets the content type headers as application/json. However, during reauthorization attempts, this header was missing on the second api request.

You’ll notice how I grab the token from the authentication/authorization slice, and if it exists, I pass it along. An additional consideration was to evaluate if the token had expired. However, I have concerns that the drift or intentional difference between the browsers clock and the server may cause some issues. I may still do something if the expiration window starts to come close, and perhaps include a server time to compare with. The end goal would be to save myself from making two calls for the same data if I can preemptively request a new authorization token.

One of the main “Gotcha’s” with this apiSlice is with hot module replacement. Although I am injecting the reducer on the fly, any subsequent injection of the apiSlice throws merge errors.

This file is rarely touched, especially after I separated the endpoints for each feaure, so it’s not much of an inconvenience.

Reauthorization

I’m using JSON Web Tokens (JWT) to authorize my requests with my REST API endpoints. The standard setup for this is to have one short lived token for requesting data, and a refresh token that remains active for much longer, but can only be used to request authorization tokens. This is a fairly common standard with many websites, and introduces the ability to have a single-sign-on approach for multiple API services so long as all servers have been configured to successfully verify the JWT signature.

Due to the asynchronous nature of api queries, I use a Mutex so that only one of the failed queries will attempt to refresh the authorization token while the others wait and try again. Normally, I would go out to the authorization endpoints to refresh the token, but I have a few things working against me. First, the authentication API is dependent on the apiSlice. Second, I don’t have access to any endpoints already injected into the apiSlice. Next, the baseQuery is in a sensitive state, in that another 401 Unauthorized code could lead us into an blocked loop waiting for a mutex that we’ve already locked. Last, there is no guarantee that the auth state has already been injected into the application store. So in this scenario, we need to call the base query directly. Since we can’t call the authorization endpoint, this also affects the authSlice from setting up external reducers to update the authorization token after it’s issued. As a result, we need to dispatch an action announcing that the token has been issued.

@/features/api/reauthorize.ts
import { BaseQueryApi, BaseQueryFn, FetchBaseQueryMeta } from "@reduxjs/toolkit/query";
import { Mutex } from 'async-mutex';
import * as auth from '@/features/auth/shared';
import { FetchBaseQueryError } from "@/types/FetchBaseQueryError";

const mutex = new Mutex();

export const reauthorize = async (
  unauthorizedResult: {
    error?: FetchBaseQueryError;
    data?: any;
    meta?: FetchBaseQueryMeta;
  },
  baseQuery: BaseQueryFn,
  args: any,
  api: BaseQueryApi,
  extraOptions: object
) => {
  const state = api.getState();
  const hasRefreshToken =
    auth.selectHasRefreshToken(state);

  if (!hasRefreshToken) {
    api.dispatch(auth.logout());
    return unauthorizedResult;
  }

  if (mutex.isLocked()) {
    await mutex.waitForUnlock();
    return await baseQuery(args, api, extraOptions);
  }

  const release = await mutex.acquire();
  const refreshToken = auth.selectRefreshToken(state);
  try {
    const refreshResult = await baseQuery(
      {
        url: 'auth/refresh',
        method: 'POST',
        body: { refreshToken },
      },
      api,
      extraOptions,
    );
    if (refreshResult.data && !refreshResult.error) {
      const data = refreshResult.data as { authorizationToken: string };
      api.dispatch(auth.authorizationTokenIssued(data.authorizationToken));
      return await baseQuery(args, api, extraOptions);
    } else {
      api.dispatch(auth.logout());
      return unauthorizedResult;
    }
  } finally {
    release();
  }
}

Endpoints

Now let’s take a look at the end points, and how they are injected into the apiSlice. This is still an area that I’m rehashing from time to time, so nothing is set in stone. Usually my endpoints directly correlate with a feature, so I keep them in the same folder with my feature slices. My REST API has document resources, and has the following pattern:

/api/{document}/{collection}/{collection-id}/{store}/{store-id}

With that, the “document” is the basis for my api endpoint files. My authSlice makes use of two of these documents: authentication & authorization. An api endpoint imports the apiSlice, causing it to inject into the application store if it isn’t already present. It then injects the endpoints.

@/features/auth/authorizationApi.ts
import { MultiItemResponse } from "@/types/MultiItemResponse";
import { apiSlice } from "@/features/api/apiSlice";
import { jsonWebToken } from '@/features/auth/jsonWebToken';

interface RefreshToken {
  refreshToken: string
}
interface AuthorizationToken {
  authorizationToken: string
}
type Tokens = RefreshToken & AuthorizationToken;

export const authorizationApi = apiSlice.injectEndpoints({
  endpoints: build => ({
    logout: build.mutation<void, Partial<Tokens>>({
      query: ({ authorizationToken, refreshToken }) => {
        const claims = jsonWebToken(refreshToken);
        const tokenId = claims.id;
        return {
          url: `/authorization/refresh-tokens/${tokenId}`,
          method: 'DELETE',
          body: { authorizationToken, refreshToken }
        }
      },
    }),
    refreshToken: build.mutation<AuthorizationToken, RefreshToken>({
      query: ({ refreshToken }) => {
        const claims = jsonWebToken(refreshToken);
        const tokenId = claims.id;
        return ({
          url: `/authorization/refresh-tokens/${tokenId}/authorization-tokens`,
          method: 'POST',
          body: { refreshToken }
        })
      },
    }),
    listAccounts: build.query<MultiItemResponse<string>, void>({
      query: () => '/authentication/accounts'
    }),
    getAccounts: build.query<object, string>({
      query: (id) => `/authentication/accounts/${id}`
    })
  })
});

const {
  endpoints: {
    logout,
    refreshToken,
    listAccounts,
    getAccounts
  }
} = authorizationApi;
export {
  logout,
  refreshToken,
  listAccounts,
  getAccounts
}
@/features/auth/authenticationApi.ts
import { apiSlice } from "@/features/api/apiSlice";

interface AuthorizationTokens {
  authorizationToken: string,
  refreshToken: string
}
interface Credentials {
  username: string,
  password: string
}
interface ChangedPassword extends Credentials {
  currentPassword: string
}
export const authenticationApi = apiSlice.injectEndpoints({
  endpoints: (build) => ({
    login: build.mutation<AuthorizationTokens, Credentials>({
      query: ({ username, password }) => ({
        url: `/authentication/accounts/${username}`,
        method: 'POST',
        body: { action: 'login', password }
      }),
    }),
    register: build.mutation<AuthorizationTokens, Credentials>({
      query: ({ username, password }) => ({
        url: '/authentication/accounts',
        method: 'POST',
        body: { username, password }
      }),
    }),
    changePassword: build.mutation<AuthorizationTokens, ChangedPassword>({
      query: ({
        username,
        currentPassword,
        password
      }) => ({
        url: `/authentication/accounts/${username}`,
        method: 'POST',
        body: {
          action: 'change password',
          password,
          currentPassword
        }
      })
    })
  })
});

const {
  endpoints: {
    changePassword,
    login,
    register
  }
} = authenticationApi;
export {
  changePassword,
  login,
  register
}

Listening to RTK Query

With the use of RTK Query, the state of request to perform various fetch requests are no longer necessary to maintain in the authSlice. It’s been reduced down to only storing the authorization token, and the refresh token. However, I still need to catch when some of the states change on specific queries. createSlice allows us to specify extraReducers to update our local slice’s state when an external action is observed. However, in some cases, we need to do things outside of the store, such as dispatch other actions, work with the local storage, or start fetching data. For this, we have listener middleware. We saw earlier how to inject a reducer. The same method is used to inject middleware as an additional parameter.

@/features/auth/authSlice.ts
import { PayloadAction, createListenerMiddleware, createSelector, isAnyOf } from "@reduxjs/toolkit";
import { Persistence } from "@/utils/Persistence";
import { restore as appRestore } from '@/app/actions';
import { createAppSlice } from "@/app/createAppSlice";
import { jsonWebToken } from "./jsonWebToken";
import { authenticationApi } from "./authenticationApi";
import { authorizationApi } from "./authorizationApi";
import { name, reducerPath, AuthState } from './shared';
import { injectReducer } from "@/app/injectReducer";

const initialState: AuthState = {};

const selectIsAuthenticated = (state: AuthState) => (state.authorizationToken ?? '') !== '';
const selectToken = (state: AuthState) => state.authorizationToken;

export const authSlice = createAppSlice({
  name,
  reducerPath,
  initialState,
  reducers: create => ({
    logout: create.reducer((
      state
    ) => {
      delete state.authorizationToken;
      delete state.refreshToken;
    }),
    refreshTokenRestored: create.reducer(
      (state, action: PayloadAction<string>) => {
        state.refreshToken = action.payload;
      }),
    authorizationTokenIssued: create.reducer(
      (state, action: PayloadAction<string>) => {
        state.authorizationToken = action.payload;
      }),
  }),
  extraReducers: builder => {
    builder.addMatcher(
      authenticationApi.endpoints.login.matchFulfilled,
      (state, action) => {
        state.authorizationToken = action.payload.authorizationToken;
        state.refreshToken = action.payload.refreshToken;
      }
    ).addMatcher(
      authorizationApi.endpoints.logout.matchFulfilled,
      (state) => {
        delete state.authorizationToken;
        delete state.refreshToken;
      }
    ).addMatcher(
      authenticationApi.endpoints.changePassword.matchFulfilled,
      (state, action) => {
        state.authorizationToken = action.payload.authorizationToken;
        state.refreshToken = action.payload.refreshToken;
      }
    ).addMatcher(
      authenticationApi.endpoints.register.matchFulfilled,
      (state, action) => {
        state.authorizationToken = action.payload.authorizationToken;
        state.refreshToken = action.payload.refreshToken;
      }
    ).addMatcher(
      authorizationApi.endpoints.refreshToken.matchFulfilled,
      (state, action) => {
        state.authorizationToken = action.payload.authorizationToken;
      }
    )
  },
  selectors: {
    selectIsAuthenticated,
    selectToken: state => state.authorizationToken,
    selectRefreshToken: state => state.refreshToken,
    selectHasRefreshToken: state => (state.refreshToken ?? '') !== '',
    selectUsername: createSelector(
      selectToken,
      token => jsonWebToken(token).subject ?? ''
    )
  }
});

export const {
  logout,
  authorizationTokenIssued,
  refreshTokenRestored
} = authSlice.actions;

const persistence = new Persistence<{
  refreshToken: string,
  authorizationToken: string
}>(authSlice.name);

const listener = createListenerMiddleware();

listener.startListening({
  actionCreator: logout,
  effect: async (_action, api) => {
    const value = persistence.get();
    if (!value) return;
    const { refreshToken, authorizationToken } = value;
    api.dispatch(authorizationApi.endpoints.logout.initiate({
      authorizationToken,
      refreshToken
    }));
    persistence.clear()
  }
});
listener.startListening({
  matcher: isAnyOf(
    authorizationApi.endpoints.logout.matchPending,
    authorizationApi.endpoints.logout.matchRejected,
    authorizationApi.endpoints.logout.matchFulfilled,
  ),
  effect: async () => persistence.clear()
});

const saveTokens = ({ refreshToken, authorizationToken }:
  { refreshToken: string, authorizationToken: string }
) => {
  persistence.set({ refreshToken, authorizationToken });
}
listener.startListening({
  matcher: authenticationApi.endpoints.login.matchFulfilled,
  effect: async (action) => saveTokens(action.payload)
});
listener.startListening({
  matcher: authenticationApi.endpoints.changePassword.matchFulfilled,
  effect: async (action) => saveTokens(action.payload)
});
listener.startListening({
  matcher: authenticationApi.endpoints.register.matchFulfilled,
  effect: async (action) => saveTokens(action.payload)
});

listener.startListening({
  actionCreator: appRestore,
  effect: async (_action, listenerApi) => {
    const value = persistence.get();
    if (!value) return;
    const { refreshToken, authorizationToken } = value;

    const authStatus = jsonWebToken(authorizationToken);

    if (authStatus.isValid && !authStatus.isExpired()) {
      listenerApi.dispatch(
        authorizationTokenIssued(authorizationToken)
      );
    }

    const refreshStatus = jsonWebToken(refreshToken);
    if (!refreshStatus.isValid || refreshStatus.isExpired())
      return;

    listenerApi.dispatch(refreshTokenRestored(refreshToken));

    if (!authStatus.isValid || authStatus.isExpired()) {
      listenerApi.dispatch(
        authorizationApi.endpoints.refreshToken.initiate({ refreshToken })
      );
    }
  }
});

injectReducer(
  authSlice.reducerPath,
  authSlice.reducer,
  listener.middleware
);

One of the things you’ll notice here is that I have duplicate selectors and actions that were already specified in ./shared.ts earlier. I’ve been debating on how to address this. Part of the issue here is that “state” in the context of authSlice is for the local slices state. In ./shared.ts, the selectors all call the selectSlice selector to grab the auth slice. The other issue is that the duplicate actions means that things can easily get out of sync if I don’t keep updating the two. Trying to make createSlice use the shared methods seems to go in a bad direction with little benefit. It seems the authSlice may simply be an edge case that can’t be addressed adequately to satisfy a multitude of different code styling arguments that come to mind.

RTK Query Cached

Before I head off, the last thing to address is caching, or more specifically, its tags. A small problem I ran into was that I couldn’t add tags directly to the apiSlice after it’s been created. I ran through a few different attempts when trying to decouple the tags from the apiSlice, and eventually found that you can add additional tags by calling the slices enhanceEndpoints method.

Another issue I ran into was how to tag things appropriately, regardless if they represented a list of items, or just an individual item. For now, I’m using the name @list since it doesn’t have an ID associated with it. The “@” sign is especially important as it is not a valid character for id’s to begin with. The last thing I would want is to have an item with the id of list getting cached improperly. Another issue I ran into was top-level items without a parent id. I can’t tag with null, so I fallback to 0 since no identity can have that value either.

@/features/types/classificationApi.ts
import emoji from '@lewismoten/emoji';

import { toQueryParams } from "@/utils/toQueryParams";
import { apiSlice } from "@/features/api/apiSlice";
import { tag, Tag } from "@/features/api/tags";

import { Identified } from "@/types/Identified";
import { MultiItemResponse } from "@/types/MultiItemResponse";
import { Searchable } from "@/types/Searchable";
import { Subset } from "@/types/Subset";
import { Hierarchical } from "@/types/Hierarchical";
import { Named } from "@/types/Named";
import { Ordered } from "@/types/Ordered";
import { Iconic } from "@/types/Iconic";
import { Identity } from "@/types/Identity";

interface Movement {
  id: string,
  parentId: string | null,
  direction: 'up' | 'down' | 'top' | 'bottom'
};
type Item = Identified & Named & Partial<Iconic> & Partial<Ordered> & Hierarchical;
type NewItem = Omit<Item, keyof Identified>;

const TAG_TYPE = `type ${emoji.fox}` as const;
const TAG_TYPE_FEATURE =
  `${TAG_TYPE} feature ${emoji.fairy}` as const;

export const tagType: Tag<typeof TAG_TYPE> =
  (value) => tag(value, TAG_TYPE);

export const tagTypeFeature: Tag<typeof TAG_TYPE_FEATURE> =
  (value) => tag(value, TAG_TYPE_FEATURE);

type FindResults = MultiItemResponse<string>;
type FindRequest = Partial<Subset & Hierarchical & Searchable>;
type IconChange = Identified & Partial<Iconic>;
type ChangeName = Identified & Named;

export const classificationApi = apiSlice.enhanceEndpoints({
  addTagTypes: [
    TAG_TYPE,
    TAG_TYPE_FEATURE
  ]
}).injectEndpoints({
  endpoints: (build) => ({
    findTypes: build.query<FindResults, FindRequest>({
      query: ({ limit, offset, parentId, search }) => {
        const params = toQueryParams({ limit, offset, search });
        return !parentId ?
          `/classification/types${params}` :
          `/classification/types/${parentId}/children${params}`
      },
      providesTags: () => [tagType('@list')]
    }),
    getTypeFeatures: build.query<string[], Identity | null>({
      query: id => `/classification/types/${id}/features`,
      providesTags: (result, _error, id) =>
        result ? [tagTypeFeature(id ?? "0")] : []
    }),
    getType: build.query<Item, Identity>({
      query: id => `/classification/types/${id}`,
      providesTags: (_result, _error, id) => [tagType(id)]
    }),
    modifyType: build.mutation<void, Item>({
      query: ({ id, ...item }) => ({
        url: `/classification/types/${id}`,
        method: 'PUT',
        body: item
      }),
      invalidatesTags: (_result, _error, { id }) =>
        [tagType('@list'), tagType(id)]
    }),
    createType: build.mutation<Identity, NewItem>({
      query: (item) => ({
        url: '/classification/types',
        method: 'POST',
        body: item
      }),
      invalidatesTags: id => id ? [tagType('@list')] : []
    }),
    deleteType: build.mutation<void, Identity>({
      query: id => ({
        url: `/classification/types/${id}`,
        method: 'DELETE'
      }),
      invalidatesTags: (_result, _error, id) =>
        [tagType('@list'), tagType(id)]
    }),
    reorderTypes: build.mutation<void, Ordered[]>({
      query: orders => ({
        url: `/classification/types/${orders[0].id}`,
        method: 'POST',
        body: { action: 'reorder', orders }
      }),
      invalidatesTags: (_result, _error, orders) =>
        [tagType('@list'), ...orders.map(tagType)]
    }),
    moveType: build.mutation<void, Movement>({
      query: ({ id, parentId, direction }) => ({
        url: `/classification/types/${id}`,
        method: 'POST',
        body: { action: 'move', parentId, direction }
      }),
      invalidatesTags: () => [tagType('@list')]
    }),
    renameType: build.mutation<void, ChangeName>({
      query: ({ id, name }) => ({
        url: `/classification/types/${id}`,
        method: 'POST',
        body: { action: 'rename', name }
      }),
      invalidatesTags: (_result, _error, { id }) => [tagType(id)]
    }),
    changeTypeIcon: build.mutation<void, IconChange>({
      query: ({ id, iconSetId, iconId }) => ({
        url: `/classification/types/${id}`,
        method: 'POST',
        body: { action: 'change icon', iconSetId, iconId }
      }),
      invalidatesTags: (_result, _error, { id }) => [tagType(id)]
    }),
  })
});

const {
  endpoints: {
    renameType,
    moveType,
    reorderTypes,
    deleteType,
    createType,
    modifyType,
    getType,
    getTypeFeatures,
    findTypes
  }
} = classificationApi;
export {
  renameType,
  moveType,
  reorderTypes,
  deleteType,
  createType,
  modifyType,
  getType,
  getTypeFeatures,
  findTypes
}

And with that, we have the guts of a redux project loading reducers and middleware on the fly with support for hot module reloading, using RTK Query, caching, and code splitting.

Discover more from Lewis Moten

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

Continue reading