DEV Community

Cover image for Build Own version of Redux toolkit using Observer design pattern.
Rasel Mahmud
Rasel Mahmud

Posted on

Build Own version of Redux toolkit using Observer design pattern.

Introduction:

A few months ago, I built my own Redux toolkit. Now, I have completed a basic e-commerce project, and in this project, I used my own Redux toolkit.

This project we will build store, createSlice, createAsyncThunk, useDispatch, useSelectorfrom scratch.

Preview Gif

Overview

Key Features

  • Sync/Async actions handled.
  • Store state management
  • Dispatch
  • RTK Query (coming soon).
  • createSlice.
  • createAsyncThunk.
  • useDispatch.
  • useSelector Hook implementation

Live link:

https://rsl-redux-shop.netlify.app

Source code:

https://github.com/rasel-mahmud-dev/rsl-redux

Project Structure

We have created a monorepo project using Lerna. It consists of two packages: a redux library and a React e-commerce project.

Project Directory:
- .gitignore
- .idea
- README.md
- app.js
- example/
    |-- .eslintrc.cjs
    |-- .gitignore
    |-- README.md
    |-- dist
    |-- index.html
    |-- node_modules
    |-- package-lock.json
    |-- package.json
    |-- postcss.config.js
    |-- public
    |-- src/
        |-- App.css
        |-- App.jsx
        |-- assets
        |-- axios
        |-- components
        |-- index.css
        |-- layout
        |-- main.jsx
        |-- pages
        |-- routes.jsx
        |-- shared.scss
        |-- store
                        │   ├── actions/
                        │   ├── adminAction.js
                        │   ├── authAction.js
                        │   ├── cartAction.js
                        │   ├── categoryAction.js
                        │   ├── productAction.js
                        │   ├── questionsAction.js
                        │   ├── reviewAction.js
                        │   └── wishlistAction.js
                        │   │
                        ├── slices/
                        │   ├── authSlice.js
                        │   ├── cartSlice.js
                        │   └── productSlice.js
                        ├── store.js
        |-- utils
    |-- tailwind.config.js
    |-- vite.config.js

- lerna.json
- node_modules
- package-lock.json
- package.json
- rsl-redux/
    - dist
    - jest.config.js
    - node_modules
    - package.json
    - readme.md
    - rollup.config.js
    - src/
        |-- configureStore.ts
        |-- createApi.ts
        |-- createAsyncAction.ts
        |-- createSlice.ts
        |-- index.ts
        |-- store.ts
        |-- useDispatch.ts
        |-- useSelector.ts
    - tsconfig.json

Enter fullscreen mode Exit fullscreen mode

Now, let's create our own Redux Toolkit library code.

Let's get started.

First of all, we will create a store which will have state, subscribe, listeners, dispatch, getState, and others.

Create Store.js


import {dispatch} from "./useDispatch"

const store = {
    state: {},
    listens: [],
    asyncActions: {},
    dispatch,
    reducerDispatch: function (reducerName, state) {
        this.state[reducerName] = {
            ...this.state[reducerName],
            ...state
        }
        this.notify()
    },
    subscribe: function (fn) {
        let index = this.listens.findIndex(lis => lis === fn)
        if (index === -1) {
            this.listens.push(fn)
        }
    },
    notify: function () {
        this.listens.forEach(lis => lis(this.state))
    },
    removeListener: function (lis) {
        this.listens = this.listens.filter(list => list !== lis)
    },
    getState(){
        return store.state
    }
}

export default store
Enter fullscreen mode Exit fullscreen mode

Here, the state is an object, which we'll store our Redux state, subscriber, async actions and etc .

listens are an array where we'll store listener callback functions to be notified when an action is dispatched to update the state.

  1. state: This property holds the current state of the redux store. It starts as an empty object {}.
  2. listens: This property is an array that holds callback functions (listeners) to be notified whenever the state is updated.
  3. asyncActions: This property is an object used to store asynchronous actions function. It used to manage asynchronous operations when dispatch some async action.
  4. dispatch: This method is responsible for updating the state of the store. It takes a value parameter, which is typically an object containing the changes to be applied to the state. It merges the new value into the existing state using the spread operator (...) and notifies all subscribed listeners.
  5. reducerDispatch: This method is similar to dispatch, but it's specifically designed for dispatching actions related to reducers. It takes two parameters: reducerName (the name of the reducer) and state (the changes to be applied to that reducer's state). It updates the state for the specified reducer and notifies listeners.
  6. subscribe: This method allows components to subscribe to changes in the store's state. It takes a callback function (fn) as a parameter and adds it to the listens array if it's not already present.
  7. notify: This method is called after state changes are made. It iterates over all subscribed listeners and invokes them, passing the updated state as an argument.
  8. removeListener: This method removes a listener from the listens array. It takes a listener function (lis) as a parameter and filters it out from the array.
  9. getState: This method returns the current state of the store.

Create createSlice.js


Now we will build createSlice:

createSlice is a function that accepts slice name, initial state, an object of reducer functions, extra-reducers and automatically generates action creators and action types that correspond to the reducers and state.

// createSlice.js
import store from "./store"

function createSlice(payload) {
    const {name: reducerName, reducers, extraReducers, initialState} = payload
    reducers["initialState"] = initialState
    let actions = {}

    for (let actionName in reducers) {
        let actionFn = reducers[actionName]

        actions[actionName] = function (arg) {
            return {
                actionFn,
                reducerName,
                payload: arg,
            }
        }
    }

    if (extraReducers) {
        extraReducers({
            addCase: function (actionCreator, reducerAction) {

                if (Object.keys((store["asyncActions"])).includes(actionCreator.type)) {
                    console.warn("Duplicate action type:: " + actionCreator.type)
                }

                if dispatch(!store["asyncActions"]) store["asyncActions"] = {}

                store["asyncActions"][actionCreator.type] = {
                    reducerName: reducerName,
                    reducerActionFn: (updatedState, result) => reducerAction(updatedState, result)
                }
            }
        })
    }

    return {
        name: reducerName,
        reducer: reducers,
        actions
    }
}

export default createSlice
Enter fullscreen mode Exit fullscreen mode

We are already aware of the purpose of the createSlice function. It facilitates the creation of various slices, or we can say reducers, which essentially allows us to segment our redux state into smaller, manageable pieces.

Parameters Deconstruction:

The function accepts a single parameter, payload, which is an object containing essential properties for defining the slice. These properties include:

  • name: A string representing the name of the slice.
  • reducers: An object containing synchronous action reducers that update the state.
  • extraReducers: An optional function for handling asynchronous actions.
  • initialState: The initial state of the slice.

Reducer Initialization:

The initial state is added to the reducers object under the key "initialState". This ensures easy access to the initial state within the reducers.

Action Creation:

For each reducer defined in the reducers object, the function generates corresponding action creator functions. These functions, when invoked, return an object encapsulating the action function, the reducer name, and the provided payload. Here's an example of how actions are created:

We store all reducer-modifying functions in the actions variable, and later, we dispatch these functions to update the slice state.

const actions = {}
for (let actionName in reducers) {
    let actionFn = reducers[actionName]
    actions[actionName] = function (args) {
        return {
            actionFn,
            reducerName,
            payload: args,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When we call setAuth with the dispatch higher-order function:

dispatch(setAuth({}))
Enter fullscreen mode Exit fullscreen mode

Then, in the reducers, setAuth will be called and , and it will receive the state reference and the action payload that we passed earlier when we modify it.

 reducers: {
      setAuth(state, action) {
          state.auth = {
              ...action.payload
          }
      } 
}
Enter fullscreen mode Exit fullscreen mode

Assigning state.auth to a certain value will update the auth property of the authState. And all subscribers will be notified and got the latest authState updated state.

Which we will be explore later on.

Handling Extra Reducers:

use case:

// authSlice.js
extraReducers: (builder) => {
      builder.addCase(loginAction.fulfilled, (state, action) => {
          state.auth = action.payload.user
          state.authLoaded = true
          localStorage.setItem('token', action.payload.token)
      })

       builder.addCase(loginAction.rejected, (state, action) => {...})
    }
Enter fullscreen mode Exit fullscreen mode

When extra reducers are specified via the extraReducers function, they are processed to handle asynchronous actions or special cases. The function provided to extraReducers likely offers a method, such as addCase, to add new cases for handling actions. If an action type already exists, duplicate action type warnings are issued.

We invoke the extraReducers function, which takes an object as parameters. This object has an addCase method.

The addCase method takes two parameters: actionCreator and reducerAction, which will be passed when calling the addCase method with the dispatch higher-order function.

// dispatch asynchronous action.
dispatch(fetchAllPosts())
Enter fullscreen mode Exit fullscreen mode

Before storing, we check if the action type already exists in the state.asyncActions object. If it does, we don't overwrite it; instead, we throw a warning indicating that the action type should be unique.

In the asyncActions object, we only store two properties: reducerName and reducerActionFn. The reducerActionFn function takes updatedState and result as arguments.

Additionally, the reducerActionFn will call the reducerAction function, which takes the addCase method as an argument.

// createSlice.js
extraReducers({
    addCase: function (actionCreator, reducerAction) {

        if(Object.keys((store["asyncActions"])).includes(actionCreator.type)){
            console.warn("Duplicate action type:: "+ actionCreator.type)
        }

        if(!store["asyncActions"]) store["asyncActions"] = {}

        store["asyncActions"][actionCreator.type] = {
            reducerName: reducerName,
            reducerActionFn: (updatedState, result) => reducerAction(updatedState, result)
        }

    }
})
Enter fullscreen mode Exit fullscreen mode

Return Object:

The function returns an object containing vital information about the slice:

  • name: The name of the slice.
  • reducer: The original reducers object.
  • actions: An object containing the action creator functions generated from the provided reducers.

CreateAsyncThunk


createAsyncThunk is a utility function that used to creating asynchronous action creators. It encapsulates the common asynchronous operation patterns, such as making API requests, and handles the pending, fulfilled, and rejected states of the asynchronous operation automatically.

use case

export const loginAction = createAsyncAction(
    "auth-login",
    async function (payload) {
        try {
            const {data} = await api.post("/auth/login", payload)
            return data
        } catch (e) {
            throw catchErrorMessage(e)
        }

    })
Enter fullscreen mode Exit fullscreen mode

Here we call createAsyncAction function and pass two parameter first one action type that should be unique and second action creator function that return promise of value that received extra-reducers as action.payload or it can return promise of rejection of data.

Now, let's look at our createAsyncThunk. It receives two parameters. The first one is the action type that will be stored in the store when we dispatch some action. It will retrieve the action creator function using the action type that we stored in the store.asyncActions object (in createSlice.js), and we invoke it.

Second one action payloadCreator function-

function createAsyncThunk(typePrefix, payloadCreator) {
    function actionCreator(arg) {
        return (dispatch, getState, extra) => {
            const result = payloadCreator(arg, {dispatch, getState, extra});
            return Object.assign(result, {
                type: typePrefix,
                arg
            })
        };
    }

    return Object.assign(actionCreator, {
        fulfilled: {type: typePrefix + "/fulfilled", payloadCreator},
        rejected: {type: typePrefix + "/rejected", payloadCreator},
        typePrefix,
    });
}

export default createAsyncThunk;
Enter fullscreen mode Exit fullscreen mode

There, we return an object that is a copy of the actionCreator function, which will be called in the dispatch HOC function.

The actionCreator function also takes an argument that will be provided when we call the action with the dispatch higher-order function.

example:

dispatch(loginAction({email: "", password: ""}))
Enter fullscreen mode Exit fullscreen mode

when we call with dispatch it again return a function return (dispatch, getState, extra) => {...}

And

extraReducers: (builder) => {
        builder.addCase(loginAction.fulfilled, (state, action) => {...})
}
Enter fullscreen mode Exit fullscreen mode

Frist when define our slice in extraReducers we define login action fullfiled status, will invoke addCase method (figure 12) and pass actionCreator function that we earlier see on asyncActionThunk.js (figure 13) it return a function and has fulfilled, rejected, type key in its prototype. and as value we had set and object that has type and payloadCreator function that was provided when we create loginAction using createAsyncAction .

we set in store action creator async function as key action type that should be unique (figure 12).

// createSlice.js
extraReducers({
    addCase: function (actionCreator, reducerAction) {

        if(Object.keys((store["asyncActions"])).includes(actionCreator.type)){
            console.warn("Duplicate action type:: "+ actionCreator.type)
        }

        if(!store["asyncActions"]) store["asyncActions"] = {}

        store["asyncActions"][actionCreator.type] = {
            reducerName: reducerName,
            reducerActionFn: (updatedState, result) => reducerAction(updatedState, result)
        }
    }
})
Enter fullscreen mode Exit fullscreen mode
export const loginAction = createAsyncAction(
    "auth-login",
    async function (payload) {
        try {
            const {data} = await api.post("/auth/login", payload)
            return data
        } catch (e) {
            throw catchErrorMessage(e)
        }

    })
Enter fullscreen mode Exit fullscreen mode

Don't worry if it seems confusing at first time! Once you give it a try, you'll quickly grasp it.

Dispatch


Now we will see in dispatch higher order function that dispatch out action and update state.

useDispatch.js

import store from "./store";

function useDispatch() {
  return dispatch;
}

function dispatch(actionObj) {
  let actionCall;
  if (typeof actionObj === "function") {
    // handle asynchronous createAsyncThunk action.
    actionCall = actionObj(dispatch, store.getState, {});
    let action = {};

    // when action creator function return promise
    if (actionCall instanceof Promise) {
      actionCall
        .then((payloadResponse) => {
          action = {
            payload: payloadResponse,
            type: actionCall.type + "/fulfilled",
          };
        })
        .catch((ex) => {
          const type = actionCall.type;
          const lastPartActionType = type.replace(/\/[^/]*$/, "");
          action = {
            payload: ex,
            type: lastPartActionType + "/rejected",
          };
        })
        .finally(() => {
          // retrieve stored async actionCreator function from store via action type
          const reducerActionInfo = store.asyncActions[action.type];

          if (reducerActionInfo) {
            const updatedState = reducerActionInfo.reducerActionFn(
              store.state[reducerActionInfo.reducerName],
              action
            );
            store.reducerDispatch(reducerActionInfo.reducerName, updatedState);
          }
        });
    } else {
      // when action creator function return plain object, arr or other.
      action = {
        payload: actionCall,
        type: actionCall.type + "/fulfilled",
      };
      const reducerActionInfo = store.asyncActions[action.type];
      if (reducerActionInfo) {
        const updatedState = reducerActionInfo.reducerActionFn(
          store.state[reducerActionInfo.reducerName],
          action
        );
        store.reducerDispatch(reducerActionInfo.reducerName, updatedState);
      }
    }
  } else {
    // handle sync action
    const { actionFn, reducerName, payload } = actionObj;
    let sliceState = store["state"][reducerName];
    actionFn(sliceState, { payload });
    store.reducerDispatch(reducerName, sliceState);
    actionCall = payload;
  }

  return {
    unwrap() {
      return actionCall;
    },
  };
}

export default useDispatch;
Enter fullscreen mode Exit fullscreen mode

The useDispatch function follows the React hook pattern, returning a function that acts as our dispatch function.

The dispatch function takes an argument, which can either be a plain object or an asynchronous function.

Here loginAction is an asynchronous action. it make server post request with user email & password and return user login info.

dispatch(loginAction({email, password})) 
Enter fullscreen mode Exit fullscreen mode

If you don’t use ReactJS in your project, then you can use the store.dispatch method to dispatch actions.

store.dispatch(loginAction({email, password})) 
Enter fullscreen mode Exit fullscreen mode

Here, loginAction represents an asynchronous action. It initiates a server POST request with the user's email and password, aiming to retrieve the user's login information. Upon successful completion, it returns the user login information.

In Figure 15, actionObj becomes a function returned by createAsyncThunk.

// createAsyncThunk.js
return (dispatch, getState, extra) => {
    const result = payloadCreator(arg, {dispatch, getState, extra});
    return Object.assign(result, {
        type: typePrefix,
        arg
    })
}; 
Enter fullscreen mode Exit fullscreen mode

The actionObj then returns the action result, either a promise or a plain object.

If the promise is fulfilled, we receive the result and create the action object:

 action = {
    payload: ex,
    type: lastPartActionType + "/fulfilled",
  };
Enter fullscreen mode Exit fullscreen mode

Otherwise, if it's rejected:

 action = {
    payload: ex,
    type: lastPartActionType + "/rejected",
  };
Enter fullscreen mode Exit fullscreen mode

This action is then passed to the builder.addCase method as the second parameter.

In the final block, we retrieve the async action handler function using the action type as the key, which we previously stored when calling the createSlice function.

const reducerActionInfo = store.asyncActions[action.type];
Enter fullscreen mode Exit fullscreen mode

We refer to it as reducerActionInfo, an object with two properties: reducerName and reducerActionFn, which are set here:

// createSlice.js
store["asyncActions"][actionCreator.type] = {
    reducerName: reducerName,
    reducerActionFn: (updatedState, result) => reducerAction(updatedState, result)
}
Enter fullscreen mode Exit fullscreen mode

reducerActionFn is a function that takes two arguments: the updated state used to mutate the slice state, and the action object containing the value and type. We mutate the slice state using this value:

const updatedState = reducerActionInfo.reducerActionFn(
      store.state[reducerActionInfo.reducerName],
      action
);
store.reducerDispatch(reducerActionInfo.reducerName, updatedState);
Enter fullscreen mode Exit fullscreen mode

now we call store.reducerDispatch method to emit event to all listener to get updated state value.

store.js

  // store.js
const store = {
        ...
        reducerDispatch: function (reducerName, state) {
            this.state[reducerName] = {
                ...this.state[reducerName],
                ...state
            }
            this.notify()
        },
        subscribe: function (fn) {
            let index = this.listens.findIndex(lis => lis === fn)
            if (index === -1) {
                this.listens.push(fn)
            }
        },
        notify: function () {
            this.listens.forEach(lis => lis(this.state))
        },
        ...
}
Enter fullscreen mode Exit fullscreen mode

The reducerDispatch method takes two arguments: the reducer name and the updated state. We immutably update our state and call the notify method, which sends all listeners the updated state.

We are Done But wait a second;

We are done, but wait a second; we need to access the slice state and also get the updated slice state when some actions are dispatched.

To do this, we create a hook called useSelector, similar to the Redux implementation.

The useSelector hook allows us to access our reducer. It subscribes to our store behind the scenes, so when a dispatch method is called, it will be triggered, and component will be re-render.

Create useSelector


// here full implementation..

// useSelector.js
import { useEffect, useState } from "react";
import store from "./store";
import isArray from "./utils/isArray";
import isObject from "./utils/isObject";

function useSelector(cb) {
    let ini = {};
    const selectedState = cb(store.state);

    if(isArray(selectedState)){
        ini = [...selectedState]
    }else if(isObject(selectedState)) {
        ini = {...selectedState}
    }

    const [state, setState] = useState(ini); // Initialize state with the specific part

    const listener = (gState) => {
        const selectedState = cb(gState);
        setState(selectedState)
    };

    useEffect(() => {
        // Subscribe to the store when the components mounts
        store.subscribe(listener);

        // Clean up the subscription when the components unmounts
        return () => {
            store.removeListener(listener);
        };

    }, [selectedState]); // Only resubscribe if the callback function changes

    // Return the selected state
    return state;
}

export default useSelector;
Enter fullscreen mode Exit fullscreen mode

The useSelector hook function takes an argument to access a specific portion of the state's value. It also subscribes to this state portion. If this portion changes, the component will re-render because it's used in the useState hook.

 useEffect(() => {
    // Subscribe to the store when the components mounts
    store.subscribe(listener);

    // Clean up the subscription when the components unmounts
    return () => {
        store.removeListener(listener);
    };

}, [selectedState]); // Only resubscribe if the callback function changes
Enter fullscreen mode Exit fullscreen mode

Inside useEffect, we establish the subscription to the store

const [state, setState] = useState(ini); // Initialize state with the specific part

const listener = (gState) => {
    const selectedState = cb(gState);
    setState(selectedState)
};
Enter fullscreen mode Exit fullscreen mode

This is our listener function.

When an action is dispatched, the listener function is invoked because we store its reference. In this function, we call the cb callback passed from the component, which returns a portion of our state value.

We then store this returned value using the useState hook, triggering a re-render in our component.

Create configure Store:


// configureStore.js
import store from "./store";

function configureStore({reducer}) {
    for (const reducerKey in reducer) {
        const initialState = reducer[reducerKey]?.["initialState"]
        store.state = {
            ...store.state,
            [reducerKey]: initialState || undefined
        }
    }
    return store
}

export default configureStore
Enter fullscreen mode Exit fullscreen mode

The purpose of the configureStore function in the provided code snippet is to initialize the global state of the Redux-like store with the initial state values provided for each reducer. and also it return our application store object reference.

Finally we are done.

Our redux driver code completed: now we use these to our application.

With Vanila JS Project:

import {createSlice, configureStore} from "rsl-redux"

const blogSlice = createSlice({
    name: "blogState",
    initialState: {
        posts: []
    },
    reducers: {
        setPosts(state, action) {
            state.posts = action.payload
        },
        removePost(state, action) {
            const updatedPosts = [...state.posts]
            state.posts = updatedPosts.filter(post => post.id !== action.payload)
        }
    }
})

const {setPosts, removePost} = blogSlice.actions

const store = configureStore({
    reducer: {
        [blogSlice.name]: blogSlice.reducer
    }
})

// subscription added
store.subscribe((store) => {
    console.log("redux state", store.getState().blogState.posts)
})

const blogs = [
    {
        "id": 2,
        "title": "blog 3",
        "slug": "blog-3"
    },
    {
        "id": 3,
        "title": "blog 4",
        "slug": "blog-4"
    },
    {
        "id": 4,
        "title": "blog 5",
        "slug": "blog-5"
    },
    {
        "id": 5,
        "title": "blog 6",
        "slug": "blog-6"
    },
    {
        "id": 6,
        "title": "blog 7",
        "slug": "blog-7"
    },
    {
        "id": 7,
        "title": "blog 8",
        "slug": "blog-8"
    },
    {
        "id": 8,
        "title": "blog 9",
        "slug": "blog-9"
    },
    {
        "id": 9,
        "title": "blog 10",
        "slug": "blog-10"
    },
    {
        "id": 10,
        "title": "blog 11",
        "slug": "blog-11"
    },
    {
        "id": 11,
        "title": "blog 12",
        "slug": "blog-12"
    }
]

// store all posts in slice.
store.dispatch(setPosts(blogs))

store.dispatch(removePost(1))
store.dispatch(removePost(2))
store.dispatch(removePost(3))
store.dispatch(removePost(4))
store.dispatch(removePost(5))
store.dispatch(removePost(6))
store.dispatch(removePost(7))
store.dispatch(removePost(8))
store.dispatch(removePost(9))
store.dispatch(removePost(10))
store.dispatch(removePost(11))
Enter fullscreen mode Exit fullscreen mode

Output:

[vite] connecting...
client.ts:173 [vite] connected.
main.jsx:42 redux state (10) [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}]
main.jsx:42 redux state (10) [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}]
main.jsx:42 redux state (9) [{}, {}, {}, {}, {}, {}, {}, {}, {}]
main.jsx:42 redux state (8) [{}, {}, {}, {}, {}, {}, {}, {}]
main.jsx:42 redux state (7) [{}, {}, {}, {}, {}, {}, {}]
main.jsx:42 redux state (6) [{}, {}, {}, {}, {}, {}]
main.jsx:42 redux state (5) [{}, {}, {}, {}, {}]
main.jsx:42 redux state (4) [{}, {}, {}, {}]
main.jsx:42 redux state (3) [{}, {}, {}]
main.jsx:42 redux state (2) [{}, {}]
main.jsx:42 redux state [{}]
main.jsx:42 redux state []

Enter fullscreen mode Exit fullscreen mode

Now we use it in our React project.


Our Project store root file

import {configureStore} from "rsl-redux";
import authSlice from "./slices/authSlice.js";
import productSlice from "./slices/productSlice.js";
import cartSlice from "./slices/cartSlice.js";

const store = configureStore({
    reducer: {
        [authSlice.name]: authSlice.reducer,
        [productSlice.name]: productSlice.reducer,
        [cartSlice.name]: cartSlice.reducer
    }
})

export default store
Enter fullscreen mode Exit fullscreen mode

Slices


authSlice.js

Auth slice to store auth related all state

import { createSlice } from "rsl-redux";
import { 
    authVerifyAction, 
    createAccountAction, 
    deleteAddress, 
    deleteCustomer, 
    fetchAddresses, 
    fetchAdminCustomersProducts, 
    fetchCategoryWiseOrdersSlatsAction, 
    fetchDashboardSlatsAction, 
    fetchOrdersSlatsAction, 
    fetchOrdersSlatsSummaryAction,
    loginAction 
} from "../actions/authAction.js";

import { 
    deleteReview, 
    fetchCustomerReviews, 
    updateReviewAction
} from "../actions/reviewAction.js"
;
import { 
    deleteQuestionAnswer, 
    fetchCustomerQuestionAnswers, 
    updateQuestionAnswerAction 
} from "../actions/questionsAction.js";

const initialState = {
    auth: null,
    authLoaded: false,
    dashboardSlats: {
        "sales": [],
        "carts": [],
        "users": []
    },
    orderSlats: {},
    orderCategoryWiseSlats: {},
    dashboardSlatsSummary: {
        totalIncome: 0,
        totalSpend: 0,
        totalProducts: 0,
        totalUsers: 0,
        totalCategories: 0,
        totalOrders: 0
    },
    openSidebar: "",
    addresses: [],
    customerReviews: {},
    customerQuestions: {}
}

const authSlice = createSlice({
    name: 'authState',
    initialState,
    reducers: {
        setAuth(state, action) { state.auth = { ...action.payload } },
        logOut(state) { localStorage.removeItem("token"); state.auth = null },
        setSidebar(state, action) { state.openSidebar = action.payload }
    },
    extraReducers: (builder) => {
       builder.addCase(loginAction.fulfilled, (state, action) => {...})
            builder.addCase(loginAction.rejected, (state, action) => {...})
            builder.addCase(fetchAdminCustomersProducts.fulfilled, (state, action) => {...})
            builder.addCase(deleteCustomer.fulfilled, (state, action) => {...})
            builder.addCase(fetchOrdersSlatsAction.fulfilled, (state, action) => {...})
            builder.addCase(fetchDashboardSlatsAction.fulfilled, (state, action) => {...})
            builder.addCase(fetchAddresses.fulfilled, (state, action) => {...})
            builder.addCase(deleteAddress.fulfilled, (state, action) => {...})
            builder.addCase(fetchCategoryWiseOrdersSlatsAction.fulfilled, (state, action) =>{...})
            builder.addCase(fetchOrdersSlatsSummaryAction.fulfilled, (state, action) => {...})
            builder.addCase(createAccountAction.fulfilled, (state, action) => {...})
            builder.addCase(authVerifyAction.fulfilled, (state, action) => {...})
            builder.addCase(authVerifyAction.rejected, (state) => {...})
            builder.addCase(fetchCustomerReviews.fulfilled, (state, action) => {...})
            builder.addCase(updateReviewAction.fulfilled, (state, action) => {...})
            builder.addCase(deleteReview.fulfilled, (state, action) => {...})
            builder.addCase(fetchCustomerQuestionAnswers.fulfilled, (state, action) => {...})
            builder.addCase(updateQuestionAnswerAction.fulfilled, (state, action) => {...})
            builder.addCase(deleteQuestionAnswer.fulfilled, (state, action) => {...})

    }
});

export const { setAuth, logOut, setSidebar } = authSlice.actions;
export default authSlice;

Enter fullscreen mode Exit fullscreen mode

productSlice.js

Store all products related state.

// productSlice.js

const initialState = {
    showCategories: [], // Array<{name: string, slug: string, image: string}>
    homeProducts: {},
    products: [], // Array<Product>
    brands: [], // Array<Brand>
    specsMapping: {},
    categoryBrands: {}, // {[catSlug: string]: Brand[]}
    specs: {},
    wishlist: [],
    orders: {}, // {1: {items: Array<Orders>, count: number} }
    adminProducts: [],
    categories: [],
    filter: {
        search: "",
        categoryIds: [],
        brandIds: [],
        pageNumber: 1,
        attributes: {}
    },
    reviews: {}, // key product id
    questionAnswers: {}, // key product id
}

const productSlice = createSlice({
    name: 'productState',
    initialState: initialState,
    reducers: {

        setFilter(state, action) {
            for (let payloadKey in action.payload) {
                state.filter[payloadKey] = action.payload[payloadKey]
            }
        },

        addToWishlist(state, action) {
            state.wishlist.push(action.payload)
        },

        removeFromWishlist(state, action) {
            state.wishlist = state.wishlist.filter(item => item.productId !== action?.pyload)
        }

    },

    extraReducers: (builder) => {
                builder.addCase(fetchProducts.fulfilled, (state, action) => {...})
                builder.addCase(fetchWishlists.fulfilled, (state, action) => {...})
                builder.addCase(fetchOrdersAction.fulfilled, (state, action) => {...})
                builder.addCase(fetchAdminProducts.fulfilled, (state, action) => {...})
                builder.addCase(fetchAdminDashboardProducts.fulfilled, (state, action) => {...})
                builder.addCase(fetchCategoryBrands.fulfilled, (state, action) => {...})
                builder.addCase(fetchAttributeSpec.fulfilled, (state, action) => {...})
                builder.addCase(fetchAttributeSpecMapping.fulfilled, (state, action) => {...})
                builder.addCase(fetchCategories.fulfilled, (state, action) => {...})
                builder.addCase(deleteAdminProduct.fulfilled, (state, action) => {...})
                builder.addCase(fetchBrands.fulfilled, (state, action) => {...})
                builder.addCase(deleteBrand.fulfilled, (state, action) => {...})
                builder.addCase(deleteCategory.fulfilled, (state, action) => {...})

                // Reviews
                builder.addCase(fetchReviews.fulfilled, (state, action) => {...})
                builder.addCase(addReviewAction.fulfilled, (state, action) => {...})

                // Question and answers
                builder.addCase(fetchQuestionAnswers.fulfilled, (state, action) => {...})
                builder.addCase(addQuestionAnswerAction.fulfilled, (state, action) => {...})
    }
})

export const {removeFromWishlist, addToWishlist, setFilter} = productSlice.actions
export default productSlice

Enter fullscreen mode Exit fullscreen mode

In productSlice.js, we handle the state related to products within our e-commerce application. This slice plays a crucial role in managing product data, including details, categories, brands, reviews, questionAnswers, product attributes and wishlist.

Asynchronous actions, managed through middleware like Redux Thunk or Redux Saga, handle tasks such as fetching product data from an API. You'll find action creators like fetchProducts, fetchBrands, fetchCategories, and fetchReviews, triggering API requests to update the Redux store with the retrieved data.

Actions:


actions/
   ├── adminAction.js
   ├── authAction.js
   ├── cartAction.js
   ├── categoryAction.js
   ├── productAction.js
   ├── questionsAction.js
   ├── reviewAction.js
   └── wishlistAction.js
Enter fullscreen mode Exit fullscreen mode

authAction.js

// authAction.js
import {createAsyncAction} from "rsl-redux";
import {api} from "../../axios";
import catchErrorMessage from "../../utils/catchErrorMessage.js";

export const loginAction = createAsyncAction("auth-login", async function (payload) {...})

export const createAccountAction = createAsyncAction("create_account", async function (payload) {...})

export const fetchAddresses = createAsyncAction("fetchAddresses", async function () {...})

export const deleteAddress = createAsyncAction("deleteAddress", async function (id) {...})

export const authVerifyAction = createAsyncAction("verify_auth_account", async function () {...})

export const fetchAdminCustomersProducts = createAsyncAction("fetchAdminCustomersProducts", async function () {...})

export const deleteCustomer = createAsyncAction("deleteCustomer", async function (id) {...})

export const fetchDashboardSlatsAction = createAsyncAction("fetchDashboardSlatsAction", async function ({year, role, taskList = []}) {...})

export const fetchOrdersSlatsAction = createAsyncAction("fetchOrdersSlatsAction", async function ({year, role}) {...})

export const fetchCartsSlatsAction = createAsyncAction("fetchCartsSlatsAction", async function ({year, role, taskList = []}) {...})

export const fetchCategoryWiseOrdersSlatsAction = createAsyncAction("fetchCategoryWiseOrdersSlatsAction", async function ({year, role, type}) {...})

export const fetchOrdersSlatsSummaryAction = createAsyncAction("fetchOrdersSlatsSummaryAction", async function ({role, taskList}) {...})

Enter fullscreen mode Exit fullscreen mode

create our fetch posts and delete post async actions

// productAction.js

import {createAsyncAction} from "rsl-redux";
import {api} from "../../axios/index.js";
import catchErrorMessage from "../../utils/catchErrorMessage.js";

export const fetchProducts = createAsyncAction("fetch-products", async function (pageNumber) {...})

export const fetchOrdersAction = createAsyncAction("fetchOrdersAction", async function (pageNumber) {...})

export const searchProductAction = createAsyncAction("search-products", async function (text) {...})

export const deleteBrand = createAsyncAction("deleteBrand", async function (brandId) {...})

Enter fullscreen mode Exit fullscreen mode

Access Store State


Now we access store state and dispatch some actions.

import React, {useEffect, useRef, useState} from 'react';
import {useParams, useSearchParams} from "react-router-dom";
import {useDispatch, useSelector} from "rsl-redux";
import Product from "../components/Product.jsx";
import {api} from "../axios/index.js";
import Breadcrumb from "../components/Breadcrumb.jsx";
import {FaAngleRight} from "react-icons/fa";
import Loader from "../components/Loader.jsx";
import {HiBars4} from "react-icons/hi2";
import {setSidebar} from "../store/slices/authSlice.js";
import {fetchAttributeSpec, fetchAttributeSpecMapping, fetchCategoryBrands} from "../store/actions/categoryAction.js";
import Popup from "../components/Popup.jsx";
import {setFilter} from "../store/slices/productSlice.js";

const SearchProduct = () => {
    const [getQuery] = useSearchParams()
    const {categoryName} = useParams()

    const filterObj = useRef({
        attributes: {}, brandIds: [],
        categoryIds: []

    })
    const {openSidebar} = useSelector(state => state.authState)

    const {categories, filter,  categoryBrands, specs} = useSelector(state => state.productState)

    const [expandAttributes, setExpandAttributes] = useState(["brand_id"])

    const [pagination] = useState({
        page: 1,
        totalPage: 10
    })

    const dispatch = useDispatch()

    let selectedCategory = categories.find(cat => cat.slug === categoryName)

    const [searchProuduct, setSearchProduct] = useState([])

    const text = getQuery.get("search")
    const [isSearching, setSearching] = useState(false)

    useEffect(() => {
        if (categoryName) {
            dispatch(fetchCategoryBrands(categoryName))
            dispatch(fetchAttributeSpec(categoryName))
            dispatch(fetchAttributeSpecMapping())
        }
    }, [categoryName]);

    useEffect(() => {
        dispatch(setFilter({
            categoryIds: categoryName ? [categoryName] : [],
            search: text,
        }))
    }, [categoryName, text])

    useEffect(() => {
        filterProduct(filter)
    }, [filter.attributes, filter.brandIds, filter.search])

    function filterProduct(filter) {...}


     return (
           <div>...</div>
      )
}
Enter fullscreen mode Exit fullscreen mode

Here use, dispatch some action

dispatch(fetchCategoryBrands(categoryName))
dispatch(fetchAttributeSpec(categoryName))
dispatch(fetchAttributeSpecMapping())

And access state using useSelector hook.

const {categories, filter, categoryBrands, specs} = useSelector(state => state.productState)

NOTE:

Please review any unintentional errors in the writing with forgiveness and a kind perspective. Also, I apologize for my English.

Part 2, I will include RTK Query soon.

Top comments (1)

Collapse
 
ashsajal profile image
Ashfiquzzaman Sajal

Great, Keep working! ⚡