I've been experimenting with Hooks lately and looking more into how can I replace Redux with useContext
and useReduer
. For me, Vuex was more intuitive when I first got into stores and I prefer their state management pattern, so I tried to build something close to that using the React Context.
I aim to have one store/context for each page in my app or to have the ability to pull the store/context up, so it's available globally if needed. The final custom useStore()
hook should return a store with the following parts:
{ state, mutations, actions, getters }
Components can then dispatch actions with actions.dispatch({type: 'my-action', payload})
(actions commit mutations) or directly commit mutations with mutations.commit({ type: 'my-mutation', payload})
. Mutations then mutate the state (using useReducer
), which finally causes a rerender.
For my example, I have two entities inside ./models. User
(context/store provided globally) and Post
(context/store provided on it's page):
// User.ts
export interface User {
id: number
username: string
website: string
}
// Post.ts
export interface Post {
id: number
userId: number
title: string
}
I then create the reducers ./store/{entity}/structure/reducer.ts:
import { UserState } from './types'
import { UserMutations } from './types';
export function userReducer(state: UserState, mutation: UserMutations): UserState {
switch (mutation.type) {
// ...
case 'set-users':
return { ...state, users: [...state.users, ...mutation.users] }
// ...
}
}
Switch through mutations from ./store/{entity}/structure/mutations.ts
import { User } from '../../../models/User';
import { AxiosError } from 'axios';
export const setUsers = (users: User[]) => ({
type: 'set-users',
users
} as const);
To get the state ./store/{entity}/structure/types/index.ts:
export interface UserState {
isLoading: boolean
error: AxiosError
users: User[]
}
Any heavier work (fetching data, etc.) before committing a mutation is located inside actions ./store/{entity}/structure/actions.ts:
import { UserMutations, UserActions } from "./types";
import axios, { AxiosResponse } from 'axios';
import { GET_USERS_URL, User } from "../../../models/User";
import { API_BASE_URL } from "../../../util/utils";
export const loadUsers = () => ({
type: 'load-users'
} as const);
export const initActions = (commit: React.Dispatch<UserMutations>) => {
const dispatch: React.Dispatch<UserActions> = async (action) => {
switch (action.type) {
case 'load-users':
try {
commit({ type: 'set-loading', isLoading: true })
const res: AxiosResponse<User[]> = await axios.get(`${API_BASE_URL}${GET_USERS_URL}`)
if (res.status === 200) {
const users: User[] = res.data.map((apiUser) => ({
id: apiUser.id,
username: apiUser.username,
website: apiUser.website
}))
commit({ type: 'set-users', users })
}
} catch (error) {
commit({ type: 'set-error', error })
} finally {
commit({ type: 'set-loading', isLoading: false })
}
break;
default:
break;
}
}
return dispatch
}
Additionally, a new derived state can be computed based on store state using getters ./store/{entity}/structure/getters.ts:
import { UserState, UserGetters } from "./types"
export const getters = (state: Readonly<UserState>): UserGetters => {
return {
usersReversed: [...state.users].reverse()
}
}
Finally, everything is initialized and glued together inside ./store/{entity}/Context.tsx:
import React, { createContext, useReducer } from 'react'
import { UserStore, UserState } from './structure/types'
import { userReducer } from './structure/reducer'
import { getters } from './structure/getters'
import { initActions } from './structure/actions'
import { AxiosError } from 'axios'
const initialStore: UserStore = {
state: {
isLoading: false,
error: {} as AxiosError,
users: []
} as UserState,
getters: {
usersReversed: []
},
mutations: {
commit: () => {}
},
actions: {
dispatch: () => {}
}
}
export const UserContext = createContext<UserStore>(initialStore)
export const UserContextProvider: React.FC = (props) => {
const [state, commit] = useReducer(userReducer, initialStore.state)
const store: UserStore = {
state,
getters: getters(state),
actions: {
dispatch: initActions(commit)
},
mutations: {
commit
}
}
return (
<UserContext.Provider value={store}>
{props.children}
</UserContext.Provider>
)
}
For a syntactic sugar, I wrap the useContext()
hook with a custom one:
import { useContext } from 'react'
import { UserContext } from './UserContext'
const useUserStore = () => {
return useContext(UserContext)
}
export default useUserStore
After providing the context, the store can be used as such:
const { actions, getters, mutations, state } = useUserStore()
useEffect(() => {
actions.dispatch({ type: 'load-users' })
}, [])
Are there any optimizations I can do? What are the biggest cons when comparing to redux? Here is the repo, any feedback is appreciated.
Edit 1:
I've wrapped the useContext()
with a custom useUserStore()
hook, so it can be used as
const { actions, getters, mutations, state } = useUserStore()
and so the store/context terms are unified when using the store.