Managing state effectively is key to building scalable and maintainable frontend applications. While popular libraries like Redux, Zustand, Recoil and MobX offer powerful solutions, there are times when you want something custom – whether you’re trying to simplify logic or just understand how things really work under the hood.
In this blog, I’ll walk you through how you can build your own state management system, called Pulse Store, or you can give any name to it. It’s a Redux-inspired, fully working solution built with plain JavaScript (ES6+).
The goal here isn’t just to replace Redux – it’s to learn how such a tool functions behind the scenes. If you’ve ever wondered how state flows in Redux, how actions trigger updates, or how reducers are managed, then Pulse Store is a great way to explore all of that in a clean, understandable way.
So, whether you’re working on a lightweight project that doesn’t need a heavy state library or you’re just curious about building things from scratch, this guide will give you the clarity and tools you need to implement your own custom solution with complete control.
Prerequisites
Before we start, make sure you have the following:
- Node.js v16 or above
- npm or yarn
- Basic familiarity with JavaScript ES6, React, and Redux.
- React/Next.js Application
Create createPulseStore.js and createBlock.js files – The Heart of the System
File 1: createPulseStore – The Store Manager
First, create a file createPulseStore.js inside ‘/src/pulse/’ directory. This file is responsible for defining and managing the state and centralized store in the React/Next.js app.
This is equivalent to the Redux store. It:
- Combines all block states into a single state
- Provides send() function to dispatch actions
- Allow subscribe() to track state changes
- Expose getState() to get updated state/read current state.
Create a function in this file with the createPulseStore name and create two variables in it, one for states and the second for channels
export function createPulseStore(blocks) { const state = {}; const channels = []; blocks.map((item) => { state[item.name] = item.state; }); }
- State: This is the internal object that will hold the reference to the block’s state in your application.
- Channels: This array holds listener functions (like components subscribed to state). Whenever any state updates, all listeners inside this array will be notified.
- We will take blocks in the arguments to get the initial state while creating the store, and assign the initial value to the state with the help of a map.
Next, we will create a function inside the createPulseStore function to expose the state value.
export function createPulseStore(blocks) { … const getState = () => state; }
Let’s create a new function to add all listener functions to the channels array and call the listener function immediately with the latest or initial state value.
export function createPulseStore(blocks) { … const subscribe = (listener) => { channels.push(listener); listener(state); }; }
But there is an issue, when you add any listener to the channel array, it will always call even after your component was demounted from the DOM. To avoid this problem, we need to implement unsubscribe logic here. To achieve this, we just simply need to return a function that will remove your listener function from the channels array, and whenever we want to unsubscribe from the listener, we just simply need to call the returned function.
export function createPulseStore(blocks) { … const subscribe = (listener) => { channels.push(listener); listener(state); return () => { const index = channels.indexOf(listener); channels.splice(index, 1); }; }; }
Now let’s create the last function of the createPulseStore, which is a send function. The send function is similar to the dispatch function of Redux. This function will take a block instance as an argument, which state needs to be update,d and after the state update, it will call all listener functions through the channels array to notify with the updated state value of the store
const send = async (block) => { state[block.name] = { ...block.state }; channels.forEach((channel) => { channel(state); }); };
Lastly, we need to return all the inner functions from the createPulseStore function. And the implementation of create-store is complete with all functionalities. Below is the complete code of the ‘src/pulse/createPulseStore.js’ file
export function createPulseStore(blocks) { const state = {}; const channels = []; blocks.map((item) => { state[item.name] = item.state; }); const getState = () => state; const subscribe = (listener) => { channels.push(listener); listener(state); return () => { const index = channels.indexOf(listener); channels.splice(index, 1); }; }; const send = async (block) => { state[block.name] = { ...block.state }; channels.forEach((channel) => { channel(state); }); }; return { send, getState, subscribe, }; }
File 2: createBlock – Similar to a Reducer
Create another file createBlock.js inside ‘/src/pulse/’ directory and create a function with name createBlock. This file function is responsible for defining individual “blocks” of the state. Each block has:
- A unique name
- An individual state object
- Handler functions, which are the same as Redux’s action for updating the state.
export function createBlock(name, initialState, handlerFunctions) { const state = initialState; let handlers = {}; for (let i in handlerFunction) { handlers = { ...handlers, [i]: (payload) => { handlerFunction [i](state, payload); return { state, name, handlers}; }, }; } return { state, name, handlers, }; }
In the createBlock function, we have created two variables for state and handler. Assign the initial state value to the state variable. There is also a for loop in this function that transforms your handler function with the block’s state value. When you call this handler function with only the payload, you can expect the block’s state to be included while creating the handler function. And lastly, return state, name, and handlers from this function as an object.
This is pretty good for all we need for custom state management (custom-redux), and both files (createPulseStore and createBlock) are one-time processes. Now it’s time to use this custom redux (pulse state management) in a React/Next.js application and test it.
We will create an application where logged-in user details will be stored and shared throughout the application.
Create Your Blocks (reducer)
Each block represents a slice of your application’s state, like a reducer in Redux. You must define the block name, initial state, and handler functions for updating the state, which will be passed as parameters in the createBlock function.
const userBlock = createBlock( "user", { userInfo: {} }, { updateUser: (state, payload) => { state.userInfo = { id: payload.id, name: payload.name, } }, logout: async (state) => { state.userInfo = {}; }, } ); export const { updateUser, logout } = userBlock.handlers;
In the above code, “user” is the block name, “{ userInfo: {} }” is the initial state value with an empty user info object and a handlers object that contains the updateUser and logout action functions. In the updateUser function, you can expect state and payload as parameters, where the state param is nothing but a global state of this block, and the payload will be anything like a string value, a number value, an array, or an object that was passed while calling this action/handler function. And the second handler (logout) will not be taking any payload and will remove the userInfo by replacing it with an empty object. After this, we simply need to export all handler functions by destructuring from blockName.handlers. Similarly, you can create multiple blocks with the help of the createBlock function.
Create Store
We can create our store instance using the createPulseStore function by passing all block instances in it.
export const store = createPulseStore([userBlock])
You can now import this store whenever and wherever it is needed. If you have multiple blocks, you need to pass them like this:
export const store = createPulseStore([userBlock, productBlock, counterBlock, …,])
And Boom, all custom redux implementation and setup is completed. Now it’s time to use it in the app.
Using in React Components
Here’s how you use this custom state management system in your React components.
import { useEffect, useState } from "react"; import { store, updateUser } from "./App"; const Header = () => { const [userInfo, setUserDetails] = useState({}); useEffect(() => { const unsubcribe = store.subscribe((state) => { console.log("state=>", state.user) setUserDetails(state.user.userInfo); }); return unsubcribe; }, []) const handleLogin = () => { const userDetail = { id: 2, name: "Afnan Danish" } // this is static user info, we can fetch user info from any api store.send(updateUser(userDetail)) } return ( <header> <h3>Logo</h3> { userInfo.id ? <h4>Welcome, {userInfo.name}</h4> : <button onClick={handleLogin}>Login</button> } </header> ) } export default Header;
In the above component, we’ve created a userInfo state using the useState hook and initialized with an empty object. We’ve added conditional rendering: when userInfo is available (i.e, has an id), the component displays a welcome message with the user’s name; otherwise, it shows a login button.
And in the useEffect hook, the store.subscribe function sets up a listener that gets triggered (called) whenever the store’s state is updated. This callback receives the latest/updated state, and we can use it to update our local userInfo state using setUserInfo function.
To update the global state in the store, we must call the handler (action) function with the help of store.send() function. As you can see from the handleLogin function, inside this function, we passed the userDetail object as a payload (param) to the updateUser handler, which we previously defined in the userBlock in the app.js file. Similarly, if you want to update any other state from a different block, you can import its corresponding handler and send (dispatch) it with store.send().
If you want to remove or reset the user information from the store (i.e during logout), you simply need to dispatch the logout handler without any payload, as it’s already implemented in the userBlock.
store.send(logout())
Output
Create a Hook to Updated State
Let’s create custom react-hooks instead of subscribing in every component. Create a new file with the name ‘createPicker.js’ inside the ‘src/pulse/’ folder.
import { useLayoutEffect, useState } from "react"; export const createPicker = (store) => { return { usePick: (picker) => { const [pick, setPick] = useState(picker(store.getState())); useLayoutEffect(() => { const unSubcribe = store.subscribe((state) => { setPick(picker(state)); }); return unSubcribe; }, []); return pick; }, }; };
And you must create this usePick hook in the app.js file by passing the store instance. Below is the complete code of app.js file.
App.js File
import "./App.css"; import Dashboard from "./Dashboard"; import Header from "./Header"; import SideBar from "./SiderBar"; import { createPulseStore } from "./pulse/createPulseStore"; import { createBlock } from "./pulse/createBlock"; import { createPicker } from "./pulse/createPicker"; const userBlock = createBlock( "user", { userInfo: {} }, { updateUser: (state, value) => { state.userInfo = { id: value.id, name: value.name, } }, logout: async (state) => { state.userInfo = {}; }, } ); export const { updateUser, logout } = userBlock.handlers; export const store = createPulseStore([ userBlock ]); export const { usePick } = createPicker(store); function App() { return ( <> <Header /> <main> <SideBar /> <Dashboard /> </main> </> ); } export default App;
You can create Sidebar and Dashboard components too, with any content, or remove them from the app.js file.
Updated Headers Component Code with Custom-Hook
import { store, updateUser, usePick } from "./App"; const Header = () => { const { userInfo } = usePick((state) => state.user); const handleLogin = () => { const userDetail = { id: 2, name: "Afnan Danish" } // this is static user info, we can fetch user info from any api too store.send(updateUser(userDetail)) } return ( <header> <h3>Logo</h3> { userInfo.id ? <h4>Welcome, {userInfo.name}</h4> : <button onClick={handleLogin}>Login</button> } </header> ) } export default Header;
In the above example, you need to pass a callback function in the usePick hook. You can expect the state in the function, which will give you all state values. You can return a block from the callback function. In the above example, we are returning the user block state by simply adding store.user and picking userInfo by destructuring it. You can use the below code, which will workthe same as the above one:
const userInfo = usePick((state) => state.user.userInfo);
If you want to obtain all block states as you do in the store.subscribe function, you can get it like this:
const data = usePick((state) => state); // you will get all blocks state object in the data
Next Steps & Expansion Ideas
The Pulse-Store you’ve built serves as a powerful foundation to understand how Redux-like state management works under the hood. But this is just the beginning. Here are some unique directions to explore and extend your store:
- Async Flow Handling: Add support for asynchronous logic, such as API calls, and delays using Promise.
- Custom Middleware: Create your own middleware pipeline to intercept and handle state updates – for logging, validation, event analytics, or side effects.
- And Many More…
Conclusion
Building your own state management systems like Pulse Store not only helps you understand how tools like Redux, Zustand or MobX work under the hood but also gives you complete control over how state flows in your application. This approach might not replace production-ready libraries, but it’s a valuable exercise to deepen your grasp of core frontend architecture principle. This level of understanding is essential for debugging, scaling applications or even building your own micro-framework tailored to your app’s unique needs.
Source: Read MoreÂ