Building a Simple Global State Management System with TypeScript and React

Keshinro QudusKeshinro Qudus
11 min read

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:

  1. If the initial value of the store is a function (i.e., a StoreGetter), it calls the function and passes it a get 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.

  2. If the initial value of the store is not a function, it simply uses the initial value as the new value of the store.

  3. If the new value of the store is a Promise, it waits for the Promise to resolve, and then sets the resolved value as the new value of the store.

  4. 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.

0
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,.