Listener Middleware

For the most part, I’ve been de-normalizing my code today. I often try to separate my functions into individual files to increase the chance of dropping unused code via tree shaking, have smaller files, and spot problems with dependent variables. With all of the imports and type definitions necessary to make TypeScript happy, these smaller files tend to have a lot of boiler plate code just in import statements alone. I’ve brought the reigns back in and most of my slice files contain most of their logic now. Having everything in a single file is also part of the Ducks pattern in React Redux.

Microsoft Designer: Image Creator Prompt

Upgraded a React Redux web application to conform to the Ducks Pattern, Specifically the Ducks Middleware Pattern.

Next was converting my project to use the @/feature prefix in my import statements. This type of folder structure can be setup with Create React App using the redux template in JavaScript or TypeScript.

npx create-react-app my-app --template redux
npx create-react-app my-app --template redux-typescript

This alias was done via TypeScripts tsconfig.json file by specifying the base url and various paths as compiler options. I had trouble getting it to work at first, as my configuration file was using references for various environments such as testing, node, and the application. I’ve tried various configurations, but in the end, I dropped the reference files from my project as they weren’t actively being used, and were copied from a prior project. After that, everything worked.

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@/*": [
        "*"
      ],
    }
  },
  "include": [
    "src",
    "vite.config.ts"
  ]
}

Actually, it wasn’t all perfect. Vite also had to be configured to read the file with the vite-tsconfig-paths plugin.

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  server: {
    port: 4200
  },
  plugins: [
    tsconfigPaths(),
    react()
  ],
})

Other than that, I started tackling the Saga issue. As a reminder, the Redux Style Guide has this to say

Now that data fetch operations have been removed from my sagas, they are left doing one of two things. The first scenario is storing and retrieving data from the local storage. This isn’t a data fetch operation in the traditional sense where you are waiting for a response. Technically, you could do this within a reducer. However, reducers should have no side effects, and should always return the same data – so no dates, random number generators, and no modification or reading of external logic.

The other thing that my sagas do is react to actions. For example, once someone performs a Drag & Drop operation, I need to see if the current slice was affected by using a selector to get pending changes. Once those changes are found, I then dispatch an action to the api with those changes.

Let’s take a look at the authentication saga as it was storing the slice in local storage.

auth/saga.ts
const persistence = new Persistence<AuthState>(authSlice.name);

export default function* saga() {
  yield all([
    takeEvery(
      logout.type,
      function* worker() {
        yield call(persistence.clear);
      }
    ),
    takeEvery(
      replace.type,
      function* worker(action: ReturnType<typeof replace>) {
        yield call(persistence.set, action.payload);
      }
    ),
    takeEvery(
      rootRestore.type,
      function* worker(_action: ReturnType<typeof rootRestore>) {
        const value: AuthState | undefined = yield call(persistence.get);
        if (value) yield put(replace(value));
      }
    )
  ]);
}

You can see a little class called “Persistence” that wraps the logic to store and retrieve the data, as well as to clear it out.

Redux has a helper function create listener middleware. I setup an individual listener for the auth slice.

authSlice.ts
const persistence = new Persistence<AuthState>(authSlice.name);
const listener = createListenerMiddleware();
export const middleware = listener.middleware;
listener.startListening({
  actionCreator: logout,
  effect: async () => persistence.clear()
});
listener.startListening({
  actionCreator: replace,
  effect: async (action) => persistence.set(action.payload)
});
listener.startListening({
  actionCreator: appRestore,
  effect: async (_action, api) => {
    const value: AuthState | undefined = persistence.get();
    if (!value) return;
    api.dispatch(replace(value));
  }
});

Now that’s a bit more compact. What’s impressive is that everything is typed. I no longer have to deal with return values from yield calls having to be typed. If I had typed them wrong, TypeScript couldn’t tell me that things didn’t match up. Using the listener, the effect action parameter is based on the return value of the actionCreator.

That’s great, but – how do we use selectors? The api parameter provides a function to get the current state. From there, you can call your selectors – but TypeScript isn’t going to let you off easy unless you specify the type of your state when creating the listener.

types/saga.ts
export default function* saga() {
  yield takeEvery(
    dragDrop.type,
    function* worker() {
      const moved: Ordered[] = yield select(selectMoved);
      if (moved.length === 0) return;
      yield put(modifyTypePositions.initiate(moved));
    }
  )
}
typesSlice.ts
const listener = createListenerMiddleware<{
  [typesSlice.reducerPath]: TypesState
}>();
export const middleware = listener.middleware;
listener.startListening({
  actionCreator: dragDrop,
  effect: async (_action, api) => {
    const state = api.getState();
    const moved = typesSlice.selectors.selectMoved(state);
    if (moved.length === 0) return;
    api.dispatch(modifyTypePositions.initiate(moved));
  }
});

By exporting our middleware from our slice file, this follows the Ducks-Middleware pattern. For other ways to tie the listeners together, see Organizing Listeners in Files. Let’s tie the two together.

app/store.ts
// Old way with Saga
const store = configureStore({
  reducer,
  middleware: getDefaultMiddleware => getDefaultMiddleware()
    .concat(sagaMiddleware, api.middleware),
});

// New way with Listeners
const store = configureStore({
  reducer,
  middleware: getDefaultMiddleware => getDefaultMiddleware()
    .concat(
      authSlice.middleware,
      typesSlice.middleware,
      api.middleware
    ),
});

Well, guess what? It’s time to gut the saga library, and all supporting libraries.

  • @redux-saga/core
  • redux-saga-routines
  • @types/redux-saga-routines
  • @codejamboree/action-builder

While reviewing my packages, I also noticed that reselect and immer were still specified as dependencies. They are no longer imported directly in any of my project files, and are exposed via the Redux JS Toolkit. The switched to using createSlice has removed some of the boilerplate code involved in setting up selectors and reducers.

Persistence

Since I referenced it earlier, I’ll demonstrate my persistence class. It’s not all that impressive. It’s just a typed wrapper to access a specific key on the local storage, and serialize/deserialize a typed value.

Persistence.ts
export class Persistence<T> {
  private key: string;

  constructor(key: string) {
    this.key = key;
  }
  get = (): T | undefined => {
    const json = localStorage.getItem(this.key);
    if (json === null) return;
    try {
      return JSON.parse(json, reviver) as T;
    } catch (e) { }
  }
  set = (value: T) => {
    const json = JSON.stringify(value, replacer, '  ')
    localStorage.setItem(this.key, json);
  }
  clear = () => localStorage.removeItem(this.key);
}
interface SerializedDate {
  __type: 'date',
  value: string
};

const replacer = (_key: string, value: any) => {
  if (!(value instanceof Date)) return value;
  const serialized: SerializedDate = {
    __type: 'date',
    value: value.toISOString()
  };
  return serialized;
}
const reviver = (_key: string, value: any) => {
  if (!isDate(value)) return value;
  try {
    return new Date(value.value);
  } catch (e) {
    return value;
  }
}
const isoPattern = /\d\d\d\d-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3])(:([0-5]\d)){2}\.\d\d\dZ/;

const isDate = (value: any): value is SerializedDate => {
  if (typeof value !== 'object' || value === null) return false;
  if (!('__type' in value)) return false;
  if (value.__type !== 'date') return false;
  if (!('value' in value)) return false;
  if (typeof value.value !== 'string') return false;
  if (!isoPattern.test(value.value)) return false;
  return true;
}

I made some effort to serialize dates as well, as that’s the one thing JavaScript seems to have problems serializing/deserializing with JSON. By default, JSON.stringify will serialize dates as ISO strings. The problem lies in parsing the string back into an object. It just parses back as a string, rather than a date. If you treat all strings that conform to the ISO Date format, you run into the risk that a user-entered value of an ISO date will convert as a Date instead of a string.

My answer was to use a replacer/reviver to convert dates into serializable objects that specify a __type key as “date”. I was debating if I should save the numerical value, or the ISO formatted date. The number would be smaller, and easier to work with – but unreadable. We also have a year 2038 problem where unix timestamps will overflow, unless JavaScript reserves more bits to store the larger numbers. I opted to serialize as ISO formatted dates. Their longevity goes to the year 9999, and I could visually see and verify that data is stored correctly. I could make assumptions that once I see the __type key as a date, then all is good. I tried to go the extra mile and conform that the ISO formatted date looked mostly correct. It’s not going to catch if someone specified February with 31 days, but it’s going to catch most scenarios of invalid data.

Discover more from Lewis Moten

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

Continue reading