Miroslav Nikolov

Miroslav Nikolov

React Global State as Local State

May 10, 2023

React global state access

How about working with the global state in React similarly to the local one (useState)? Having a handy useGlobalState hook to read the global store object and forcing all subscribed components to re-render when it updates. Without the boilerplate around. No Redux, and Context involved. That simple.

The following little guide explains the implementation in details. It’s little because the hook itself is simple but robust enough to serve production apps that don't need much more than access to a global space. No dependencies, plus testing ↘︎ and TypeScript support ↘︎ covered. Take a look.

Ergonomic Global Store Interface #

A React hook for components:

function Profile() {
  const [user] = useGlobalState("user");

  return (
    <div className="Profile">
      <h3>{user.name}</h3>
      <p>{user.age}</p>
    </div>
  );
}

Same API as useStateconst [data, setData] = useGlobalState(storeSlice);

It's also possible to access the global state outside React components:

// Global store access outside React components

import { setGlobalState, getGlobalState } from "../store.config.js";

getGlobalState("user");

setGlobalState("user", {
  name: "Johny Bo",
  age: 23,
});

The store configuration for the above code is very straightforward. There are no Redux-like reducers, selectors, or actions. You need a single .js/ts config file (notice the store.config.js import) to initialize the store.

// store.config.js
// Initialize the global store

import createGlobalState from "./createGlobalState.js";

const initialState = {
  user: {
    name: "Justin Case",
    age: 37,
  },
};

export const { useGlobalState, setGlobalState, getGlobalState } =
  createGlobalState(initialState);

That's all you do — call createGlobalState() with an initial store value and export the hook and the two functions it returns.

Then, any place/component in your codebase imports these functions from that same config to get/set store data.

import {
  useGlobalState,
  setGlobalState,
  getGlobalState,
} from "../store.config.js";

Global Store (Pub/Sub) Implementation #

The JavaScript implementation of createGlobalState() is around 60 lines of code (not counting the comments). It features getting and setting store values — both from and outside components — and handling updates (re-renders) for all components "hooked" to a specific store slice.

Example folder setup:

-- src
   |-- components
   |-- store
   |   |-- createGlobalState.js
   |   |-- createGlobalState.test.js
   |   |-- store.config.js

📌 createGlobalState.js implementation (all you need as a takeaway):

// createGlobalState.js

import { useState, useEffect } from "react";

function createGlobalState(initialState = {}) {
  const state = initialState;
  const subscribers = {};

  // Subscribers per state slice/prop
  // e.g. state.user
  for (const i in state) {
    subscribers[i] = [];
  }

  function useGlobalState(key) {
    // To prevent getting/setting keys that aren't initialized
    // May go away with the TypeScript implementation
    if (!state.hasOwnProperty(key)) {
      throw new Error("This key does not exist in the store");
    }

    // Global state as React local state
    const [stateChunk, setStateChunk] = useState(state[key]);

    useEffect(() => {
      subscribers[key].push(setStateChunk);

      // Cleanup: subscriber removal after effect execution
      return () => {
        const index = subscribers[key].findIndex((fn) => fn === setStateChunk);
        subscribers[key].splice(index, 1);
      };
    }, [key]);

    return [
      stateChunk,
      (value) => {
        setGlobalState(key, value);
      },
    ];
  }

  // Useful for setting state outside React components
  const setGlobalState = (key, value = null) => {
    // To prevent setting keys that aren't initialized
    if (!state.hasOwnProperty(key)) {
      throw new Error("This key does not exist in the store");
    }

    state[key] = value;

    subscribers[key].forEach((subscriber) => {
      subscriber(value);
    });
  };

  // Useful for getting state outside React components
  const getGlobalState = (key) => {
    // To prevent getting keys that aren't initialized
    if (!state.hasOwnProperty(key)) {
      throw new Error("This key does not exist in the store");
    }
    return state[key];
  };

  return {
    useGlobalState,
    setGlobalState,
    getGlobalState,
  };
}

export default createGlobalState;

TypeScript Support #

You can ensure type safety both in the store configuration file and within components when working with the hook itself:

// store.config.ts

import createGlobalState from "./createGlobalState";

export type User = {
  name: string;
  age: number;
};

type State = {
  user: User | null;
};

export const initialState: State = {
  user: {
    name: "Justin Case",
    age: 37,
  },
};

export const { useGlobalState, setGlobalState, getGlobalState } =
  createGlobalState<State>(initialState);

Type safety in components:

// Profile.tsx

import { useGlobalState, type User } from "../store.config.js";

function Profile() {
  const [user, setUser] = useGlobalState<User>("user");
  const [name, setName] = useState(user?.name);
  const [age, setAge] = useState(user?.age);

  const handleSubmit = () => {
    setUser({ name, age });
  };

  return (
    <form
      onSubmit={() => {
        setUser({ name, age });
      }}
    >
      <input
        type="text"
        name="name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input
        type="text"
        name="age"
        value={age}
        onChange={(e) => setAge(e.target.value)}
      />
    </form>
  );
}
createGlobalState TypeScript implementation (expand)
import { useState, useEffect } from "react";

function createGlobalState<InitialState = object>(initialState: InitialState) {
  const state: InitialState = initialState;
  const subscribers = {} as { [K in keyof InitialState]: any[] };

  for (const i in state) {
    subscribers[i] = [];
  }

  function useGlobalState<SliceState>(
    key: keyof InitialState
  ): [SliceState, (value?: SliceState) => void] {
    if (!state.hasOwnProperty(key)) {
      throw new Error("Key does not exist in the store");
    }

    const [stateChunk, setStateChunk] = useState<SliceState>(
      state[key] as SliceState
    );

    useEffect(() => {
      subscribers[key].push(setStateChunk);

      return () => {
        const index = subscribers[key].findIndex((fn) => fn === setStateChunk);
        subscribers[key].splice(index, 1);
      };
    }, [key]);

    return [
      stateChunk,
      (value) => {
        setGlobalState(key, value);
      },
    ];
  }

  const setGlobalState = (key: keyof InitialState, value = null) => {
    if (!state.hasOwnProperty(key)) {
      throw new Error("Key does not exist in the store");
    }

    state[key] = value;

    subscribers[key].forEach((subscriber) => {
      subscriber(value);
    });
  };

  const getGlobalState = (key: keyof InitialState) => {
    if (!state.hasOwnProperty(key)) {
      throw new Error("Key does not exist in the store");
    }
    return state[key];
  };

  return {
    useGlobalState,
    setGlobalState,
    getGlobalState,
  };
}

export default createGlobalState;

Testing and Examples #

The following example (codesandbox) shows how useGlobalState is used to get and update part of the store forcing independent components to re-render as a reaction to the change.

Not included in the experiment but modifying the state with setGlobalState (non-component) will also re-render any subscribed components.

Tests #

All tests below are based on Jest and React Testing Library. Starting with an integration one:

// Profile.js

import { useGlobalState } from "../store.config";

function Profile() {
  const [user] = useGlobalState("user");

  return (
    <div className="Profile">
      <h3>{user.name}</h3>
      <p>{user.age}</p>
    </div>
  );
}
// Profile.test.js

import { render, screen } from "@testing-library/react";
import Profile from "./Profile";
import { setGlobalState } from "../store.config";

it("should display global user data", () => {
  setGlobalState("user", {
    name: "Chris Stone",
    age: 20,
  });

  render(<Banner />);

  expect(screen.queryByText("Chris Stone")).toBeInTheDocument();
  expect(screen.queryByText("20")).toBeInTheDocument();
});
Lib tests (createGlobalState.test.tsx) are present here (expand)
// createGlobalState.test.tsx

import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import createGlobalState from "./createGlobalState";

type State = {
  a: any;
  b: any;
  c: any;
};

describe("createGlobalState", () => {
  let globalState, Component, consoleErrorMock;

  beforeEach(() => {
    // Suppress error logs in components
    consoleErrorMock = jest
      .spyOn(console, "error")
      .mockImplementation(() => jest.fn());

    globalState = createGlobalState<State>({
      a: 1,
      b: 2,
      c: 3,
    });

    Component = ({
      stateSlice,
      newValue,
    }: {
      stateSlice: string;
      newValue?: string;
    }) => {
      const { useGlobalState } = globalState;
      const [value, setValue] = useGlobalState(stateSlice);

      return (
        <p
          role="button"
          onClick={() => {
            setValue(newValue);
          }}
        >
          {value || "No such key"}
        </p>
      );
    };
  });

  afterEach(() => {
    consoleErrorMock.mockRestore();
  });

  describe("Getting default state", () => {
    it("should get default state", () => {
      const { getGlobalState } = globalState;

      expect(getGlobalState("a")).toBe(1);
      expect(getGlobalState("b")).toBe(2);
      expect(getGlobalState("c")).toBe(3);
    });

    it("should get non-existent state", () => {
      const { getGlobalState } = globalState;

      expect(() => getGlobalState("no-such-key")).toThrowError(
        "Key does not exist in the store"
      );
    });

    it("should get default state (component)", () => {
      render(<Component stateSlice="a" />);
      expect(screen.queryByText("1")).toBeInTheDocument();

      render(<Component stateSlice="b" />);
      expect(screen.queryByText("2")).toBeInTheDocument();

      render(<Component stateSlice="c" />);
      expect(screen.queryByText("3")).toBeInTheDocument();
    });

    it("should get non-existent state (component)", () => {
      expect(() => render(<Component stateSlice="no-such-key" />)).toThrowError(
        "Key does not exist in the store"
      );
    });
  });

  describe("setting state", () => {
    it("should try to set non-existent state", () => {
      const { setGlobalState } = globalState;

      expect.assertions(1);

      try {
        setGlobalState("no-such-key", "Value");
      } catch (err) {
        expect(err.message).toBe("Key does not exist in the store");
      }
    });

    it("should set state", () => {
      const { setGlobalState, getGlobalState } = globalState;

      setGlobalState("a", "Value a");
      setGlobalState("b", "Value b");
      setGlobalState("c", "Value c");

      expect(getGlobalState("a")).toBe("Value a");
      expect(getGlobalState("b")).toBe("Value b");
      expect(getGlobalState("c")).toBe("Value c");
    });

    it("should try to set non-existent state (component)", () => {
      expect(() => {
        render(<Component stateSlice="no-such-key" newValue="Value" />);
        fireEvent.click(screen.getByText("No such key"));
      }).toThrowError("Key does not exist in the store");
    });

    it("should set state (component)", () => {
      render(<Component stateSlice="a" newValue="Value" />);
      fireEvent.click(screen.getByText("1"));

      expect(screen.queryByText("Value")).toBeInTheDocument();
    });

    it("should update other components when setting state (hook -> component)", () => {
      const key = "a";

      const AnotherComponent = ({ stateSlice }: { stateSlice: string }) => {
        const [value] = globalState.useGlobalState(stateSlice);
        return value;
      };

      render(<Component stateSlice={key} newValue="New value" />);
      render(<AnotherComponent stateSlice={key} />);
      fireEvent.click(screen.getByRole("button"));

      expect(screen.queryAllByText("New value")).toHaveLength(2);
    });

    it("should update other components when setting state (manual -> component)", async () => {
      const key = "a";
      const { setGlobalState } = globalState;

      const AnotherComponent = ({ stateSlice }: { stateSlice: string }) => {
        const [value] = globalState.useGlobalState(stateSlice);
        return value;
      };

      render(<Component stateSlice={key} />);
      render(<AnotherComponent stateSlice={key} />);

      setGlobalState(key, "New value");

      expect(await screen.findAllByText("New value")).toHaveLength(2);
    });
  });
});

Closing Words #

The idea behind such basic global store implementation is not new. It's inspired by projects like react-hook-global-state but without introducing external dependencies.

While simple, createGlobalState is also extensible. Things developers appreciate in popular global state solutions — middlewares, error handling, actions, etc — can be built on top.

So far, I tried createGlobalState instead of Redux in a few projects. It is my default choice today.

💬 Discussion on Reddit


Follow on Linkedin