Building a Simple Global State Management System with TypeScript and React
In this article, we'll be exploring how to build a simple global state management system using TypeScript and React. Global state management allows us to store and share data across different components of our application, making it easier to manage and update our application state.
Introduction
State management is an important aspect of building any application. As our applications grow in complexity, managing state can become increasingly difficult. Global state management helps us simplify this process by allowing us to store and manage our application's state in a single location.
In this tutorial, we'll be using TypeScript and React to build a simple global state management system. We'll be using the observer pattern to manage state updates across our application.
The Code
The code for our global state management system consists of two main parts: the defineStore
function, which creates our state store, and the useStore
and useStoreValue
hooks, which allow us to access and update our state store.
Here's the code for our global state management system:
import React from 'react';
interface CreateStoreType < StoreType > {
get: () => StoreType,
set: (newValue: StoreType) => void,
subscribe: (callback: (newValue: StoreType) => void) => () => void
}
type StoreGetter <StoreType> = (get: <T>(a: CreateStoreType<T>) => T ) => StoreType;
type StoreValueType<T> = StoreGetter<T> | T
function defineStore <StoreType>( initialValue: StoreValueType<StoreType>):CreateStoreType<StoreType>{
let subscribers = new Set<(newValue: StoreType) => void>()
let currentValue = typeof initialValue === 'function' ? (null as StoreType) : initialValue
function get<T>(atom: CreateStoreType<T>){
let currentValue = atom.get()
atom.subscribe((newValue) => {
if (currentValue === newValue ) return;
currentValue = newValue
computeValue()
subscribers.forEach(callback => callback(currentValue))
})
return currentValue;
}
function computeValue(){
let newValue = typeof initialValue === 'function' ? (initialValue as StoreGetter<StoreType>)(get) : currentValue
if (newValue && newValue.then) {
(newValue as any as Promise<StoreType>).then((resolved) => {
currentValue = resolved
subscribers.forEach(callback => callback(currentValue))
})
} else {
currentValue = newValue
subscribers.forEach(callback => callback(currentValue))
}
}
computeValue()
return {
get: () => currentValue,
set: (newValue: StoreType) => {
currentValue = newValue;
subscribers.forEach(callback => {
callback(currentValue)
})
atom.subscribe(callback => {
subscribers.add(callback)
return () => subscribers.delete(callback)
})
},
subscribe: (callback => {
subscribers.add(callback)
return () => subscribers.delete(callback)
})
}
}
function useStore<StoreType>(atom:CreateStoreType<StoreType>){
return [
React.useSyncExternalStore(atom.subscribe, atom.get), atom.set]
}
function useStoreValue<StoreType>(atom : CreateStoreType<StoreType>) {
return React.useSyncExternalStore(atom.subscribe, atom.get)
}
Let's dive in
This code defines a simple state management system using a custom hook and a factory function that creates stores.
The CreateStoreType
interface defines an object with three methods: get
, set
, and subscribe
. get
returns the current value of the store, set
updates the store value and notifies subscribers of the change, and subscribe
allows components to listen for changes to the store value.
The defineStore
function creates a store object based on an initial value passed as an argument. If the initial value is a function, it is treated as a function that takes a get
function as an argument and returns the initial value of the store. Otherwise, the initial value is used as the current value of the store.
The get
function is called by defineStore
to get the current value of the store. It subscribes to the store to listen for changes and updates the current value when changes occur. The computeValue
function is used to compute the new value of the store based on the initial value passed to defineStore
. If the initial value is a function, it is called with get
as an argument to get the initial value of the store. If the new value is a Promise, it is resolved and the resolved value is set as the current value of the store.
The useStore
hook is used to access the store value and update the store. It takes a CreateStoreType
object as an argument and returns an array with two elements: the current value of the store and a function to update the store value.
The helper functions ? what do they do ? you are probably lost in the chevrons and maarkups, yeah, you should probably sit back and relax while I explain shortly, one afterthe other
Subscribe() - what does it do ?
let subscribers = new Set<(newValue: StoreType) => void>()
subscribe: (callback => {
subscribers.add(callback)
return () => subscribers.delete(callback)
})
The subscribe
function is a method of the CreateStoreType
interface in the provided code, which is used for listening to changes to the store value.
When a component subscribes to a store using subscribe
, it provides a callback function to be executed whenever the store value changes. This callback function will receive the updated value of the store as its argument.
The purpose of subscribe
is to allow components to react to changes in the store value, and update their rendering accordingly. Without subscribe
, components would have no way of knowing when the store value has changed, and would need to manually check the value at regular intervals, which can be inefficient.
In simpler terms, subscribe
is like a notification system that alerts components whenever the store value changes. It is necessary for ensuring that components are always up-to-date with the latest value of the store, and can re-render themselves accordingly.
Here is an example of what the callback function that waa passed as n argument to the 'subscribe' method might look like:
function handleStoreChange(newValue) {
// do something with the new store value
console.log('Store value has changed:', newValue);
}
In this example, handleStoreChange
is the callback function that will be executed whenever the store value changes. The newValue
argument passed to the function will contain the updated value of the store.
You can use any function that accepts one argument (the updated store value) as the callback for subscribe
. This allows you to define custom logic for how your components should react to changes in the store value.
why do we need to to delete the callback after adding it to the list ?
The reason we need to delete the callback after adding it to the list is to prevent memory leaks and unnecessary processing.
When a component subscribes to a store using subscribe
, it provides a callback function to be executed whenever the store value changes. The store keeps a list of all the subscribed callbacks, which it calls whenever the store value changes.
If a component subscribes to a store but never unsubscribes, its callback function will continue to be stored in the list of subscribed callbacks, even after the component is no longer being used. This can cause unnecessary processing and memory usage, especially if many components are subscribed to the store.
To prevent this, the subscribe
function returns a function that can be used to unsubscribe the callback from the store. When a component is no longer being used, it should call this function to remove its callback from the list of subscribed callbacks. This ensures that the store only calls the callbacks of currently active components, and prevents memory leaks and unnecessary processing.
ComputeValue()
The computeValue
function is a helper function that is called internally by the defineStore
function to compute the current value of the store. Its purpose is to determine the current value of the store based on its initial value and any changes that may have been made to it.
The computeValue
function takes the following steps:
If the initial value of the store is a function (i.e., a
StoreGetter
), it calls the function and passes it aget
function that can be used to retrieve the current value of the store. The result of this function call will be the new value of the store.If the initial value of the store is not a function, it simply uses the initial value as the new value of the store.
If the new value of the store is a
Promise
, it waits for thePromise
to resolve, and then sets the resolved value as the new value of the store.If the new value of the store is different from the current value of the store, it updates the current value of the store, and calls all the subscribed callback functions with the new value.
Here is the computeValue
function in code:
function computeValue() {
let newValue = typeof initialValue === 'function' ? (initialValue as StoreGetter<StoreType>)(get) : currentValue;
// If the new value is a Promise, wait for it to resolve before updating the store value
if (newValue instanceof Promise) {
currentValue = null as StoreType;
newValue.then((resolved) => {
currentValue = resolved;
subcribers.forEach(callback => callback(currentValue));
});
} else {
// If the new value is different from the current value, update the store value and call all the subscribed callbacks
if (newValue !== currentValue) {
currentValue = newValue;
subcribers.forEach(callback => callback(currentValue));
}
}
}
In summary, the computeValue
function is responsible for computing the current value of the store based on its initial value and any changes that may have been made to it. It ensures that the store value is always up-to-date and that all subscribed components are notified of any changes to the store.
Usage
To use our global state management system, we first need to create a store using the defineStore
function. We can then use the useStore
and useStoreValue
hooks to access and update
Sure, let's continue.
Now, let's take a closer look at the useStore
and useStoreValue
functions. These are custom hooks that allow React components to subscribe to changes in a store and re-render when the store value changes.
The useStore
function takes a store atom as its argument and returns an array with two elements: the current value of the store and a function to update the value of the store. The React.useSyncExternalStore
hook is used to subscribe to changes in the store and re-render the component when the store value changes.
function useStore<StoreType>(atom:CreateStoreType<StoreType>){
return [
React.useSyncExternalStore(atom.subscribe, atom.get),
atom.set
]
}
The useStoreValue
function is similar to useStore
, but it only returns the current value of the store and does not provide a function to update the store value.
function useStoreValue<StoreType>(atom : CreateStoreType<StoreType>) {
return React.useSyncExternalStore(atom.subscribe, atom.get)
}
Now that we've gone through all the parts of the code, let's see how they work together in an example.
const countAtom = defineStore(0)
function Counter() {
const [count, setCount] = useStore(countAtom)
function increment() {
setCount(count + 1)
}
return (
<div>
<button onClick={increment}>+</button>
<p>{count}</p>
</div>
)
}
In this example, we define a store atom called countAtom
with an initial value of 0. We then create a Counter
component that uses the useStore
hook to subscribe to changes in the countAtom
atom. The increment
function updates the value of the store by calling the setCount
function returned by useStore
. The count
value is then displayed in the component.
Overall, the code we've looked at is an example of how to create a simple state management system using TypeScript and React. It provides a basic framework for creating and using store atoms, subscribing to changes in store values, and updating store values. While this code is not production-ready, it can serve as a starting point for building more complex state management systems in your applications.
Building on our simple state manager, we can create more complex state management systems that handle more intricate application state structures. The example below shows how we can use our simple state manager to create derived state, asynchronous state, and key state.
Firstly, let us create a derived state. Derived state is state that depends on other states. We will create derived state using our simple state manager. The defineStore
function creates a new state. In this case, we are passing a function to defineStore
. This function takes a get
function as an argument. The get
function is used to retrieve the values of other states. In this example, we are getting the values of numberState
and stringState
, adding them together, and returning the result. The result is the derived state.
const derivedState = defineStore((get) => get(numberState) + get(stringState))
Secondly, we will create asynchronous state. Asynchronous state is state that is retrieved asynchronously. In this example, we are retrieving data from a remote server. We are using the fetch
API to retrieve data from a JSON file. The fetch
function returns a Promise, so we wrap the call in a Promise. We then pass this Promise to the defineStore
function. This creates a new state. When the Promise resolves, the state is updated with the data from the server.
const asyncState = defineStore(() => fetch("./app/data.json").then(res => res.json()))
Finally, we will create key state. Key state is state that depends on the keys of other states. In this example, we are getting the keys of the asyncState
state. We are using the get
function again to retrieve the value of the asyncState
state. We are then calling the Object.keys
function to get the keys of the object. The keys are then returned as an array.
const keyState = defineStore((get) => Object.keys(get(asyncState) ?? {}))
Now that we have our three states, we can use them in our React component. We are using the useStoreValue
function to get the values of our states. We pass in our state to the useStoreValue
function, and it returns the current value of the state. In this example, we are getting the value of derivedState
, asyncState
, and keyState
. We are then displaying the values in our component.
function App() {
const string = useStoreValue(derivedState)
const data = useStoreValue(asyncState)
const keys = useStoreValue(keyState)
return (
<>
<h1>Hello, World</h1>
<h4>{string}s have been planted so far</h4>
<center>
{JSON.stringify(data)}
<br />
<br />
{keys.map((k, i) => <li>k</li>)}
</center>
</>
)
}
In summary, our simple state manager can be used to create more complex state management systems that handle more intricate application state structures. In this example, we showed how we can use derived state, asynchronous state, and key state to manage application state.
Conclusion
In this article, we have seen how to create a simple global state management system using React hooks. We created a defineStore function that returns an object with get, set, and subscribe methods. We then used the useStore and useStoreValue hooks to subscribe to updates and get the current value of the store.
While this simple implementation might not be suitable for larger applications, it demonstrates how to use React hooks to manage global state in a simple and efficient way.
Subscribe to my newsletter
Read articles from Keshinro Qudus directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Keshinro Qudus
Keshinro Qudus
25'. Web developer based in Lagos Nigeria. I write to share my expertise with like minded people who are looking to learning and improving their skills. It's my first time blogging so you can expect more articles on web related topic. I'm mostly a Reactjs, TypeScript developer,.