Hacking Dependency Free React State Management

Written by antonkalik | Published 2023/05/11
Tech Story Tags: react | state-management | javascript | frontend-development | reactjs | javascript-development | software-development | optimization

TLDRReact has made a significant advance in the tools that help us create global state management. Weā€™ll learn about theĀ `useSyncExternalStore`Ā hook and add polish to create state management without unnecessarily rendering our components. To simplify the development process, I will use React with JavaScript.via the TL;DR App

How to create global state management in React applications without side dependencies and unnecessary rerendering

First of all, I would like to give special thanks to Jack Herrington for inspiring this topic.

React has made a significant advance in the tools that help us create global state management. React has pushed us to a new level, and Reduxā€™s huge boilerplate base and Mobxā€™s decorators have sunk into oblivion. Creating your own global state management without side dependencies is an easy challenge.

Today, weā€™ll learn about theĀ useSyncExternalStoreĀ hook and add polish to create state management without unnecessarily rendering our components.

Application structure

Before diving into the code, letā€™s check the structure of our test application. Each component will iterate with the global store. We need that structure to know which component will be rerendered with the storeā€™s manipulation.

Create components

To simplify the development process, I will useĀ https://vitejs.dev/, and choose React with JavaScript.

Here are a few steps to get started:

  • RunĀ yarn create vite
  • Choose your project name, framework:Ā React, and language variant:Ā javascript
  • Add the following:Ā cd project_nameĀ andĀ yarn
  • RemoveĀ <React.StrictMode>Ā inĀ main.jsxĀ to avoid a second rerender

Now, letā€™s create components inĀ src/components. There should be four files with the following names:Ā Header.jsx,Ā Body.jsx,Ā Shares.jsx, andĀ Footer.jsx.

Create store

In theĀ srcĀ folder, create theĀ storeĀ folder and put the following two files into it:Ā initialState.jsĀ andĀ index.js.

ForĀ initialState.js, I put some nested objects and values that will get updated in our application.

Hereā€™s what the code looks like:

export const initialState = {
  name: "John",
  age: 30,
  status: "active",
  details: {
    shares: [
      {
        symbol: "AAPL",
        price: 100,
      },
      {
        symbol: "MSFT",
        price: 200,
      },
      {
        symbol: "GOOG",
        price: 300,
      },
    ],
  },
};

Before creating the function, letā€™s figure out how the store should work and what we should expect. Similar to Redux, we can useĀ useStateĀ hooks orĀ useContextsĀ withĀ useReducerĀ and apply them across the application.

Letā€™s check the implementation withĀ useState, as shown below:

import { useState } from "react";
import { initialState } from "./initialState.js";

function createStore() {
  return {
    useStore() {
      return useState(initialState);
    },
  };
}

export default createStore();

As you can see, weā€™re gonna reuse theĀ useStateĀ across the application. No magic; itā€™s a simple implementation that clarifies which component will be rerendered after manipulating the store.

Letā€™s update ourĀ App.jsxĀ withĀ store.useStore():

import "./App.css";
import { Footer } from "./components/Footer/index.jsx";
import store from "./store/index.js";

function App() {
  console.log("App updated");
  const [state, setState] = store.useStore();

  return (
    <div>
      <div>My value: {state.name}</div>
      <div>My value: {state.age}</div>
      <button
        onClick={() =>
          setState((prevStore) => {
            return {
              ...prevStore,
              name: "New name",
              age: 100,
            };
          })
        }
      >
        Update Shares From App
      </button>
      <Footer />
    </div>
  );
}

And letā€™s haveĀ Footer.jsxĀ access the current state with the following code:

import store from "../../store";

export const Footer = () => {
  const [state] = store.useStore();
  console.log("Footer updated");

  return (
    <footer>
      <p>Footer</p>
      <p>Status: {state.status}</p>
    </footer>
  );
};

Now run the app withĀ yarn devĀ and hit the buttonĀ Update Shares From Appwith an open console.

You will see that all of our components are updated. InĀ Footer, we will readĀ statusĀ from the unmodified store, and this will always returnĀ active.

But the problem is we didnā€™t update any values inĀ FooterĀ because we got an updated object that rerendered the component. To avoid rerendering, we will create a selector and read the store from the hookĀ useSelector. Hereā€™s how to do that:

const status = useSelector((state) => state.status);

The hook will use the function selector to get the current state from our store.

Update components

Now, letā€™s create our remaining components. In each component, weā€™re gonna addĀ console.logĀ to the name of that component. An alternative solution is to use Google Chrome with React Developer Tools.

Hereā€™s what that looks like:

Now,Ā Footer.jsxĀ will useĀ useSelectorĀ from the created store.

import { useSelector } from "../../store";

export const Footer = () => {
  console.log("Footer updated");
  const status = useSelector((state) => state.status);

  return (
    <footer>
      <p>Footer</p>
      <p>Name: {status}</p>
    </footer>
  );
};

For theĀ Header, weā€™re gonna only useĀ setStateĀ from store.

import { setState }  from "../../store";

export const Header = () => {
  console.log("Header updated");

  return (
    <header>
      <p>Header</p>
      <button
        onClick={() =>
            setState((prevStore) => {
            return {
              ...prevStore,
              name: "Michael",
              age: 99,
            };
          })
        }
      >
        Update Name And Age from Header
      </button>
    </header>
  );
};

And for theĀ Body, weā€™re gonna use both functions to update and read the store.

import { useSelector, setState } from "../../store";

export const Body = () => {
  const name = useSelector((state) => state.name);
  const age = useSelector((state) => state.age);

  console.log("Body updated");

  return (
    <div className="body">
      <h1>Body</h1>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
      <button
        onClick={() =>
          setState((prevStore) => {
            return {
              ...prevStore,
              name: "Michael",
              age: 99,
            };
          })
        }
      >
        Update Name And Age from Body
      </button>
    </div>
  );
};

And the last section,Ā Shares, will use the store to read data.

import { useSelector } from "../../store";

export const Shares = () => {
  const { shares } = useSelector((state) => state.details);

  console.log("Shares updated")

  return (
    <div className="shares">
      <h1>Shares</h1>
      <ul>
        {shares.map(({ symbol, price }) => {
          return (
            <li key={price + symbol}>
              {symbol} : {price}
            </li>
          );
        })}
      </ul>
    </div>
  );
};

Finally, to wrap them all in one application, letā€™s put the components intoĀ App.jsx. To check theĀ AppĀ componentā€™s render as well, weā€™ll use theĀ setStateĀ function.

import "./App.css";
import { Header } from "./components/Header/index.jsx";
import { Body } from "./components/Body/index.jsx";
import { Shares } from "./components/Shares/index.jsx";
import { Footer } from "./components/Footer/index.jsx";
import { setState } from "./store/index.js";

function App() {
  console.log("App updated");

  return (
    <div>
      <Header />
      <Body />
      <Shares />
      <button
        onClick={() =>
          setState((prevStore) => {
            const newShare = {
              symbol: "XRP",
              price: 1.27472,
            };
            const share = prevStore.details.shares.find(
              (share) => share.symbol === newShare.symbol
            );
            if (!share) {
              return {
                ...prevStore,
                details: {
                  ...prevStore.details,
                  shares: [...prevStore.details.shares, newShare],
                },
              };
            } else {
              return prevStore;
            }
          })
        }
      >
        Update Shares From App
      </button>
      <Footer />
    </div>
  );
}

export default App;

To highlight our components, letā€™s useĀ App.cssĀ to add borders.

header, footer, .body, .shares {
  border: 1px solid #2c2c2c;
}

Store and Listeners

To prevent unnecessary redrawing, we need to create aĀ useSelectorfunction. This will improveĀ createStoreā€™s implementation withĀ subscribe.Ā SubscribeĀ notifies React about store changes. InsideĀ createStore.js, letā€™s create theĀ subscribeĀ function withĀ listeners.

function createStore() {
  let state = initialState;
  const listeners = new Set();

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };
  // ...

With this technic, we can subscribe to our store and notify React about changes. As you can see, this function will also returnĀ listeners.deleteĀ and allow us to unsubscribe. This technic came from theĀ publisher-subscriber patternĀ which lets you subscribe and unsubscribe for changes. To receive notifications about changes, we must create another function,Ā setState.

const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

return {
  setState(callback) {
    state = callback(state);
    listeners.forEach((listener) => listener(state));
  }
}

The listener is always gonna get the current state and set it toĀ listeners.

And the last part, theĀ createStoreĀ function, uses theĀ useSelectorĀ hook and lets us get all the changes from our store.

useSelector(selector) {
  return selector(state)
},

But in this case, we are not gonna be able to get updated data because we are not subscribed to our changes from the state. To fix that, we have to apply theĀ subscribeĀ function to theĀ useSyncExternalStoreĀ hook from React.

This hook takes three arguments:Ā subscribe,Ā getSnapshot, andĀ getServerSnapshotto render on the server side.

useSelector(selector) {
  return useSyncExternalStore(
    subscribe,
    () => selector(state)
  );
}

TheĀ subscribe function will register a callback to notify us about store changes. And combiningĀ () => selector(state)Ā andĀ getSnapshotĀ will return our storeā€™s current state. In this case, we wonā€™t be using server-side rendering for a while.

import { useSyncExternalStore } from "react";
import { initialState } from "./initialState.js";

function createStore() {
  let state = initialState;
  const listeners = new Set();

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  return {
    setState(callback) {
      state = callback(state);
      listeners.forEach((listener) => listener(state));
    },
    useSelector(selector) {
      return useSyncExternalStore(subscribe, () => selector(state));
    },
  };
}

const store = createStore();

export const { setState, useSelector } = store;

Now, letā€™s run our serverĀ yarn devĀ and check how the components will re-render. You will see something like this:

By clicking on the button,Ā Update Shares From App, the storeā€™s data will update. This data is used only inĀ Shares.jsxĀ , and thatā€™s the only component that has to be rerendered because other components didnā€™t receive updates.

Now, click onĀ Update Name And Age from Header, and you will see that updates only happen inĀ Body.jsx. And if you click again, nothing is gonna rerender because the data is the same. This is absolutely fine.

What About Server-Side Rendering

To sync the server-side data and store, we need to improve theĀ createStorefunction. To test that, I suggest you create aĀ Next JSĀ application and apply our created components to theĀ indexĀ view. While youā€™re at it, add theĀ getServerSidePropsĀ function to provide additional changes to the storeā€™s data.

export async function getServerSideProps() {
  return {
    props: {
      initialState: {
        ...initialState,
        name: "Black",
      },
    },
  };
}

To apply new store data from our view, we have to initialize our store with server data fromĀ props.

export default function Home(props) {
  console.log("Home updated");
  store.init(props.initialState);

  return (
    <div>
      <Header />
      <Body />
      <Shares />
      <Footer />
    </div>
  );
}

TheĀ initĀ function should get a new state and apply that to our current state. Hereā€™s what that looks like:

import { useSyncExternalStore } from "react";
import { initialState } from "./initialState.js";

function createStore() {
  let state = initialState;
  let listeners = new Set();
  let isInitialized = false;

  // ...

  return {
    init: (initState) => {
      if (!isInitialized) {
        state = initState;
      }
    },
    // ...
  };
}

const store = createStore();

export default store;
export const { setState, useSelector } = store;

The assignment will happen only once for theĀ view.

Conclusion

Itā€™s fascinating! With one function, we solved the global state management problem without any boilerplate code or unnecessary re-rendering. The hookĀ useSyncExternalStoreĀ helps us synchronize our store with our React applicationā€™s state. Just one function can connect our global storeā€™s values across the entire application.

Resources

GitHub Repo:Ā https://github.com/antonkalik/global-store


Also published here.


Written by antonkalik | Senior Software Engineer @ Amenitiz / Node JS / React
Published by HackerNoon on 2023/05/11