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.
