Recently I have been working on a React project with functional components and hooks (React 16.8+). There I use Redux to manage application states with both React hooks eg, useState, useCallback etc, and custom hooks. Custom hooks is an interesting topic to me so I did some research on what, why and how to use custom hook.
After I read some articles on custom React hooks, I realised that custom hook is such a powerful and flexible way to extract logic and share with multiple components. So the idea behind custom hook is to share the logic with multiple components but NOT to share the data and state, which means the state in the custom hook is replicated across multiple components that use the same hook. Extracting the logic for http request that gets auth token automatically is one of the use case.
However, what if we want to share the data across all components that use this hook? This could be a way to manage a shared state among components that use this hook. Basically it is like a Redux store but using custom hook. I found this particularly interesting so I want to find out and see if that is possible. Luckily I found that was mentioned in this awesome Udemy course by Maximilian Schwarzmüller. I found that is inspiring and I would like to document this. So here is how it is done.
To show how this is done, we are going to build a simple app which has 2 components, ProductItem and FavouriteProducts to showcase the state management by using the store powered by the custom hook. You can find the demo and code here.
Create a simple store
The basic idea is similar to Redux store which is inspired by the Elm Architecture. This store is abstraction of the concrete store we are going to build and handles, state sharing, registration of all components that use this store and dispatch of actions.
src/hooks-store/store.js
import { useState, useEffect } from "react";
let globalState = {};
let listeners = [];
let actions = {};
export const useStore = () => {
const setState = useState(globalState)[1];
useEffect(() => {
listeners.push(setState);
return () => {
listeners = listeners.filter((li) => li !== setState);
};
}, [setState]);
const dispatch = (actionIdentifier, payload) => {
const newState = actions[actionIdentifier](globalState, payload);
globalState = { ...globalState, ...newState };
for (const listener of listeners) {
listener(globalState);
}
};
return [globalState, dispatch];
};
export const initStore = (userActions, initialState) => {
if (initialState) {
globalState = { ...globalState, ...initialState };
}
actions = { ...actions, ...userActions };
};
A few things to note here,
- We declare globalState, listeners and actions OUTSIDE of the useStore hook function. This is how we can refer back the same variable from different components. So the data is shared across all components with this hook and we can keep track of all listeners and dispatch actions and update the state. This is essential to get all parts hooked up.
- In the hook, we basically register the setState function, which is provided by the React useState hook, for each component that uses this hook. React will ensure the state update will update the component which means we can update the component state when an action is dispatched.
- This hook returns the globalState and dispatch function for components that are either interested in the state or triggering the action.
- The useEffect function also returns a function that remove the listener as a clean up. Since that is a closure and the setState function will be the one that is registered so we can verified in the filter function.
- This base store exports a initStore function for any concrete store to add their actions and states.
Create a concrete store with the state and actions
We can now create a concrete store that will populate the empty base store with its own state and actions.
/src/hooks-store/products-store.js
import { initStore } from "./store";
const configureStore = () => {
const actions = {
TOGGLE_FAV: (currentState, productId) => {
const productIndex = currentState.products.findIndex(
(p) => p.id === productId
);
const newFavStatus = !currentState.products[productIndex].isFavorite;
const updatedProducts = [...currentState.products];
updatedProducts[productIndex] = {
...currentState.products[productIndex],
isFavorite: newFavStatus
};
return { products: updatedProducts };
}
};
initStore(actions, {
products: [
{
id: 1000,
title: "iPhone 11",
description: "Apple iPhone 11 256GB Black",
isFavorite: false
},
{
id: 2000,
title: "iPhone 7",
description: "Apple iPhone 7 128GB White",
isFavorite: false
},
{
id: 3000,
title: "iPhone X",
description: "Apple iPhone X 256GB Black",
isFavorite: false
},
{
id: 4000,
title: "iPhone 12 Pro",
description: "Apple iPhone 12 Pro 256GB White",
isFavorite: false
},
{
id: 4001,
title: "Huawei Mate 30 Pro",
description: "Huawei Mate 30 Pro 256GB",
isFavorite: false
}
]
});
};
export default configureStore;
Here we create a simple products-store with its dummy state and action. Then we use initStore function which is from our base store to add the state and action to the base store.
Using this store from different components
So our store is done, to use this store, just like using a custom hook, we import it into our components.
Initialise the store
/src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import configureProductsStore from "./hooks-store/products-store";
configureProductsStore();
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
rootElement
);
In the index.js, we initialise our concrete store by calling the configureProductStore function. That’s it. That means we can have multiple stores merged into the global store as long as they have different state attributes and actions.
ProductItem component
/src/components/ProductItem.js
import React from "react";
import { useStore } from "../hooks-store/store";
const ProductItem = (props) => {
const dispatch = useStore()[1];
const toggleFavouriteHandler = () => {
dispatch("TOGGLE_FAV", props.id);
};
return (
<li className="product-item" key={props.id}>
<div className="description">{props.description}</div>
<div className="action">
<button onClick={toggleFavouriteHandler}>Toggle Favourite</button>
</div>
</li>
);
};
export default ProductItem;
This component only cares about triggering the change to toggle product favourite attribute. It only takes the second return value from our store hook. This will change the global state as soon as the action is dispatched.
FavouriteProducts Component
import React from "react";
import { useStore } from "../hooks-store/store";
import ProductItem from "./ProductItem";
const FavouriteProducts = () => {
const state = useStore()[0];
console.log(state);
return (
<ul className="product-list">
{state.products
.filter((product) => product.isFavorite === true)
.map((product) => {
return (
<ProductItem
key={product.id}
title={product.title}
description={product.description}
id={product.id}
/>
);
})}
</ul>
);
};
export default FavouriteProducts;
This component list a list of ProductItem components but only with the isFavorite attribute set to true. The data is read from the global state which is the first return value from the useStore hook.
The App Component that renders them all
/src/App.js
import React from "react";
import "./styles.css";
import { useStore } from "./hooks-store/store";
import ProductItem from "./components/ProductItem";
import FavouriteProducts from "./components/FavouriteProducts";
export default function App() {
const state = useStore()[0];
return (
<div className="App">
<h2>Product list</h2>
<ul className="product-list">
{state.products.map((product) => {
return (
<ProductItem
key={product.id}
title={product.title}
id={product.id}
description={product.description}
/>
);
})}
</ul>
<h2>Favourite list</h2>
<FavouriteProducts />
</div>
);
}
Here we render the full product list and the favourite product list. We rely on the useStore hook to manage the state and dispatch actions.
Wrap up
Here we have a simple store to manage the global state and dispatch actions just like Redux store but using React custom hook. This works well for this little demo and more importantly it helps me better understand how hooks and Redux work. This surely works for small applications so we do not have to hook up Redux and its eco system components but for commercial applications it is recommended to use tools like Redux.
All the code is hosted in here. You can check out the running demo in this codesandbox URL. Hope this helps some people.