paint-brush
Hacking Dependency Free React State Management by@antonkalik
217 reads

Hacking Dependency Free React State Management

by Anton KalikMay 11th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

React 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.
featured image - Hacking Dependency Free React State Management
Anton Kalik HackerNoon profile picture

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.


Application components


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.jsxBody.jsxShares.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:

Highlight updates when components render in React Developer Tools


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 subscribeSubscribe 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: subscribegetSnapshot, 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:


The result of rendered components


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.