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 bycreateAction
andcreateAsyncThunk
"You can add
extraReducers
these functions let you interact or work with actions outside of the slice that houses the regularreducers
.
In the above example the actions for thefetchPageData
fetching statuses are handled withextraReducers
"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
allowscreateSlice
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
Single Responsibility Principle (SRP): Each function and module has a single responsibility and does not take on additional responsibilities that would violate this principle.
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.
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.
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.
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.