#StackBounty: #javascript #react.js #typescript #redux React Context & Hooks custom Vuex like store

Bounty: 50

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.


Get this bounty!!!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.