5
\$\begingroup\$

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 }

enter image description here

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 new

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.

asked Mar 22, 2020 at 19:06
\$\endgroup\$

0

Know someone who can answer? Share a link to this question via email, Twitter, or Facebook.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.