The cool thing about Redux...

Photo by Joan Gamell on Unsplash

The cool thing about Redux...

ยท

9 min read

Okay so maybe there is nothing "cool" about redux, but I think it is cool how you can use vanilla redux, createReducer and createSlice all in the same project if I want or if I have to.

Why use redux?

Well for me one thing I enjoy is the "separation of concerns"(SC) when managing business logic and app state.
SC; is a case by case requirement, if not given a good amount of thought, it will mess up a project and have the reverse effect on Developer Experience(DX).

Another thing that can have an undesired effect on DX is redux, so make sure you really need it for your next incomplete side project ๐Ÿ˜…๐Ÿซ ๐Ÿคฃ
If you decide "Yes I need it" then use what I am sharing below to get the most out of it, or at least to get things moving.


redux folder structure for some developers might go something like this.

- redux
    - helpers
        # hooks.ts
        # reduxActions.ts
    - middleware
        # logger.ts
    - reducers
        # menuReducer.ts
   # store.ts

Vanilla Redux

Actions

export const EVENT_MAIN_MENU_ACTIVE = 'EVENT_MAIN_MENU_ACTIVE'
export const EVENT_MAIN_MENU_HIDE = 'EVENT_MAIN_MENU_HIDE'

Reducer

Yes... The warmth of a familiar bit of code!!

// mainMenuReducer.ts
import {
  EVENT_MAIN_MENU_ACTIVE,
  EVENT_MAIN_MENU_HIDE,
} from '../../helpers/reduxActions'

export type MenuStoreState = {
  menu: MenuState
}

export type MenuState = {
  mainMenuVisible: boolean
}

export const menuDefaultState: MenuState = {
  mainMenuVisible: false,
}

export default (
  state: MenuState = menuDefaultState,
  action: { type: string },
): MenuState => {
  switch (action.type) {
    case EVENT_MAIN_MENU_ACTIVE: {
      return {
        ...state,
        mainMenuVisible: true,
      }
    }
    case EVENT_MAIN_MENU_HIDE: {
      return {
        ...state,
        mainMenuVisible: false,
      }
    }
    default: {
      return {
        ...state,
      }
    }
  }
}

Usage

import { useCallback, MouseEvent } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
  EVENT_MAIN_MENU_ACTIVE,
  EVENT_MAIN_MENU_HIDE,
} from '../../redux/helpers/actionTypes'
import { MenuStoreState } from '../../redux/reducers/menuReducer'

const useMainMenu = () => {
  const showMainMenu = useSelector<MenuStoreState>(
    state => state.menu.mainMenuVisible,
  ) as boolean

  const dispatch = useDispatch()

  const handleMainMenuClick = useCallback(
    async (event: MouseEvent<HTMLButtonElement>) => {
      event.preventDefault()
      event.stopPropagation()

      dispatch({
        showMainMenu,
        type: !showMainMenu ? EVENT_MAIN_MENU_ACTIVE : EVENT_MAIN_MENU_HIDE,
      })
    },
    [showMainMenu, dispatch],
  )
...

And that is it, that is the code to manage opening and closing a drawer that will contain the main menu of your website, usually on a mobile device.

Yes, we still need to add a logger.ts & store.ts, which is coming later.


createReducer redux

Nothing like using a well-formed hook to manage app state!

Reducer, Initial state and Actions.

All are nicely co-located.

// authedUserReducer.ts
import { createAction, createReducer, PayloadAction } from '@reduxjs/toolkit'

type UserState = {
  user: User | undefined
}

export const initialState: UserState = {
  user: undefined,
}
// In this example this is also known as reduxActions.ts
export const userData = createAction('USER_DATA', (user: User) => {
  return { payload: user }
})

const authedUserReducer = createReducer(initialState, builder => {
  builder.addCase(
    userData,
    (state, action) => {
      // payload has types inferred because of the create action!
      state.user = action.payload
    },
  )
})

export default authedUserReducer

Something pretty cool, in the above code inside the (state, action) function is that the data is mutable aka; state: WritableDraft<UserState> Do what you want with the data inside this function and no need to ...spread the data or return anything that is all taken care of ๐Ÿ‘

Usage

import { useEffect, useReducer } from 'react'
import userReducer, { userData } from '../../redux/reducers/userReducer'

type UserData = {
  user: User | undefined
}

const useGetUserData = (): UserData => {
  const [state, dispatch] = useReducer(
    authedUserReducer,
    authedUserReducer.getInitialState(),
  )

  useEffect(
    function getUserDetailsFromAuthOrSession() {
      const setUserSession = async () => {
        const user: User = await getUser()

        dispatch(userData(user))
      }

      setUserSession()
    },
    [dispatch],
  )

  return {
    user: state.user,
  }
}

export default useGetUserData

Yep that's it, nothing else to write!


createSlice redux

createSlice has quite a bit of boilerplate code, but code usage is reduced (sorry that is unintended that pun...), so it depends on where you want to write your code, more code in usage or more boilerplate pick your poison.
createSlice just like createReducer nicely packaged in the same place, if that's what you like, easy to see all the code and the moving parts. It is also nice to have all the reducers in one place to see how it all works together.

Note:: pageDataReducer.ts <- This name is misleading but rather than create another folder for slices I named it as a reducer, whatever you prefer in this case.

Slice

// pageDataReducer.ts
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { optimisticLoadArrayResource } from '../../../utils/helpers/staticAssets'
import { Subset } from '../../../utils/valueCheckers'

type PageState = {
  resources: Subset<Resource>[]
}

const initialState: PageState = { resources: [] }

export const fetchPageData = createAsyncThunk(
  'page/fetchResources',
  async (): Promise<Subset<Resource>[]> => {
    // Make the backend call.
    // NOTE:: This will run when user is unauthed but only an empty array will be returned,
    // This is why we have the optimisticLoadArrayResource fallback here and when the thunk fetch is "pending"
    const response = await getResources(ResourceTypes.resource)

    return response ?? optimisticLoadArrayResource
  },
)

export const createResource = (
  resources: Subset<Resource>[],
): Subset<Resource> => {
// This is a great place to cherry pick what you need and define a new type.
  return resources
}

const pageDataSlice = createSlice({
  name: 'page', // <- this is the name used in the store.ts
  initialState,
  reducers: {
    createPageData: (state, action: PayloadAction<Subset<Resource>>) => {
      const newResource = createResource(action.payload)
      state.resources = [newResource, ...state.resources]
    },
    updatePageData: (state, action: PayloadAction<Subset<Resource>>) => {
      const newResource = createResource(action.payload)
      const index = state.resources.findIndex(
        resource => resource.id === newResource.id,
      )
      state.resources.splice(index, 1)

      state.resources = [newResource, ...state.resources]
    },
    deletePageData: (state, action: PayloadAction<ResourceId>) => {
      const deleteId = action.payload
      const newResourceList = state.resources.filter(
        resource => resource.id !== deleteId,
      )

      state.resources = [...newResourceList]
    },
  },
  extraReducers: builder => {
    builder.addCase(fetchPageData.pending, state => {
      state.resources = optimisticLoadArrayResource
    })
    builder.addCase(
      fetchPageData.fulfilled,
      (state, action: PayloadAction<Subset<Resource>[]>) => {
        const resources = action.payload
        state.resources = resources
      },
    )
  },
})

export const pageDataReducer = pageDataSlice.reducer
export const { createPageData, updatePageData, deletePageData } =
  pageDataSlice.actions

export default pageDataSlice

What are some great things about createReducer & createSlice ?

  • First of all and most importantly you get inferred types!!
    "correctly infer the action type in the reducer based on the provided action creator. It's particularly useful for working with actions produced by createAction and createAsyncThunk"

  • You can add extraReducers these functions let you interact or work with actions outside of the slice that houses the regular reducers.
    In the above example the actions for the fetchPageData fetching statuses are handled with extraReducers
    "One of the key concepts of Redux is that each slice reducer "owns" its slice of state, and that many slice reducers can independently respond to the same action type. extraReducers allows createSlice to respond to other action types besides the types it has generated."

  • Co-location of code export what you want as you need it, reusability

Usage

The good old "wrapper" hook in a hook...

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import { ApplicationDispatch, ApplicationState } from '../store'

export const useReduxUseSelector: TypedUseSelectorHook<ApplicationState> =
  useSelector

export const useReduxUseDispatch: () => ApplicationDispatch = useDispatch

Now just dispatch your reducers in functions or as needed in any files you need to create, read, update, or delete data.

  const dispatch = useReduxUseDispatch()

  dispatch(createPageData(response))
  dispatch(fetchPageData())
  dispatch(updatePageData({ ...currentData, newData }))
  dispatch(deletePageData(payload.deleteNodeId))

For me, the above usage is just the same as importing and using a hook like useSWR or reactQuery throughout your code. One thing that can be done here, you can't do with useSWR or reactQuery hooks are fetching data before the app has mounted. ๐Ÿ‘‡

import { fetchPageData } from './redux/reducers/pageDataReducer'

const container = document.getElementById('root')

store.dispatch(fetchPageData())

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = createRoot(container!)

root.render(
  <StrictMode>
    <Provider store={store}>
      <ErrorBoundary>
        <App />
      </ErrorBoundary>
    </Provider>
  </StrictMode>,
)

Finally the Logger and the Store.

Logger

Pretty standard right not much to say here, also I am pretty sure there is a built-in option for this in the toolkit, but I am not sure, I'll leave you to find that out ๐Ÿ™Œ

import { Middleware, MiddlewareAPI, Dispatch } from 'redux'

const loggerMiddleware: Middleware =
  ({ getState }: MiddlewareAPI) =>
  // eslint-disable-next-line consistent-return
  (next: Dispatch) =>
  action => {
    const { type } = action

    if (process.env.NODE_ENV === 'development') {
      console.group(
        '๐Ÿš€ ~ file: logger.js ~ line 3 ~ logger ~ action.type',
        type,
      )
      console.info(
        '๐Ÿš€ ~ file: logger.ts ~ line 13 ~ logger -> will dispatch',
        action,
      )

      const result = next(action)
      console.info(
        '๐Ÿš€ ~ file: logger.js ~ line 8 ~ logger ~ store.getState() -> state after dispatch',
        getState(),
      )
      console.groupEnd()

      // This will likely be the action itself, unless
      // a middleware further in chain changed it.
      return result
    } else {
      const result = next(action)
      return result
    }
  }

export default loggerMiddleware

Store

import { configureStore } from '@reduxjs/toolkit'
import { applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import loggerMiddleware from './middleware/logger'

import { pageDataReducer } from './reducers/pageDataReducer'
import authedUserReducer from './reducers/authedUserReducer'
import menuReducer from './reducers/menuReducer'

const middlewareEnhancer = applyMiddleware(loggerMiddleware, thunkMiddleware)

const store = configureStore({
  devTools: process.env.NODE_ENV !== 'production',
  reducer: {
    menu: menuReducer, // vanilla redux
    page: pageDataReducer, // createSlice redux
    auth: userReducer, // createReducer redux
  },
  enhancers: [middlewareEnhancer],
})

// These below types are a big part of why we have the custom hook wrappers useReduxUseSelector & useReduxUseDispatch
export type ApplicationState = ReturnType<typeof store.getState>
export type ApplicationDispatch = typeof store.dispatch

export default store

Signoff

That is it! This app is now using 3 different implementations of redux and you wouldn't even know without looking at the code ๐Ÿ˜….

Redux is a powerful tool for managing app state in complex applications. It provides a structured approach that makes it easier to reason about.

Thank you for taking the time to read this post. If you have any questions or comments, please feel free to leave them below.


SOLID

To help you sleep easier ๐Ÿ˜…๐Ÿคฏ๐Ÿ˜Ž๐Ÿซ  this code is S.O.L.I.D

  1. Single Responsibility Principle (SRP): Each function and module has a single responsibility and does not take on additional responsibilities that would violate this principle.

  2. Open/Closed Principle (OCP): The code is open for extension but closed for modification. It achieves this by using abstraction (e.g., asyncThunkAction) to separate different concerns, allowing the code to be extensible without modifying existing code.

  3. Liskov Substitution Principle (LSP): The code implements the LSP by using interfaces (e.g., PayloadAction) to ensure that derived classes can be substituted for their base classes without affecting the correctness of the program.

  4. Interface Segregation Principle (ISP): The code follows the ISP by using interfaces to define only what is needed for a particular module, preventing unnecessary coupling between modules.

  5. Dependency Inversion Principle (DIP): The code follows the DIP by depending on abstractions (e.g., optimisticLoadArrayResource) instead of concrete implementations, allowing for greater flexibility in the implementation of the code.

Did you find this article valuable?

Support Hey Rory's blog by becoming a sponsor. Any amount is appreciated!

ย