DEV Community

Cover image for How I Wrote My Own State Manager
Georg
Georg

Posted on

How I Wrote My Own State Manager

I want to devote this article to the different ways of updating data in JS and React, using my own library, "nexus-state", as the running example. I will share detailed examples and my motivation, and explain what state managers are actually for and how they work. In the examples I will try to keep things as simple as possible, and I will not touch on typing.

Introduction

The name came to me out of nowhere. Having finished every game in one of my favourite series, "Dark Souls", I stumbled upon the earlier "Demon's Souls" (which, ironically, I never played) — or more precisely, upon the description of the Nexus. In that game it is a kind of hub where the player can teleport, level up and rest.

I liked the name. It comes from Latin and means "link", "knot", "connection" — and there was an analogy in that: a developer, much like the game's protagonist, walks through the hardships of a project and always has access to the Nexus, where they can get what they need. In development, that something is data. Put simply, it is the connection — the connection to your data.

The name was already taken on npm, so the library became "nexus-state". I will add that I wanted the people using it to keep that same feeling of having something to lean on along the thorny path of development — that connection to data, through "nexus-state".

The hero stares into a bonfire and sees the library logo

Prologue

With years of work as a UI/UX designer behind me, a little JS knowledge and some experience writing HTML and CSS, I ran into React some time around 2021. It was the logical next step in my professional growth.

I wrote components, studied hooks and enjoyed myself: it felt like I could now make any UI I could imagine actually work. But the harder the tasks became, the more often I ran into limits.

One of them was working with state. More precisely, the question of how to conveniently update the same data living in different components. That is the first question every so-called global state manager answers.

Let's look at an example. Say we have a game website where the hero's name and class have to appear in several components at once:

function App() {
  return (
    <>
      <Header />
      <Sidebar />
      <Profile />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The useState hook will not help here: it is local only, and we cannot store the same data separately in every component — the copies would drift apart.

In theory we could lift the state up to a common parent and pass the data down through props. But that approach has a price, and it becomes noticeable once there is a lot of data and a lot of components: every change re-renders the entire subtree under the component holding the state, including components that have nothing to do with that data.

Memoization hooks help for a while, but sooner or later this leads to needless complexity. There is also useContext — more on that later, but at the start of my journey it was not what I wanted to use.

And this is where the idea of a global state manager comes in, creating a shared Store we can always read current data from, data that triggers a re-render by itself once it changes.

                   Store
                     │
        ┌────────────┼────────────┐
        ↓            ↓            ↓
      Header       Sidebar      Profile
Enter fullscreen mode Exit fullscreen mode

There is no shortage of ready-made solutions. But I was only beginning to find my way around the frontend, and the most important part bothered me: when I used them, I did not understand what was happening inside. Everything works — but how exactly, nobody knows. From the outside it looks like magic.

And then I came across an article about Flux, which introduced me to the notions of "publisher/subscriber". I fell in love with that architecture and started using it.

The hero endures hard training with technologies

A Sense of Control

Flux removed all the magic, making the flow of data completely transparent. Here is a simple example of a store built on the Flux architecture, without a Dispatcher for now — we will come back to it later.

// myStore.js
// our default data
let state = {
  name: "Duncan",
  role: "knight",
};

const listeners = new Set(); // a collection of listeners

export const store = {
  // read the data
  getState() {
    return state;
  },

  // write the data
  setState(newState) {
    state = {
      ...state,
      ...newState,
    };

    // walk every subscriber and tell them something changed
    listeners.forEach((listener) => listener());
  },

  // subscribe to updates
  subscribe(listener) {
    listeners.add(listener);

    return () => listeners.delete(listener); // hand back a way to unsubscribe
  },
};
Enter fullscreen mode Exit fullscreen mode

So we now have methods for reading and updating data, used like this:

import { store } from "./myStore";

console.log("state -", store.getState()); // print the data

// we get state - { name: "Duncan", role: "knight" }
Enter fullscreen mode Exit fullscreen mode

Right now Flux has no idea React exists. The store can hold state and notify subscribers when it changes, but on its own it cannot make React re-render anything. Let's write a small React hook for that.

// useStore.js
import { useSyncExternalStore } from "react";
import { store } from "./myStore";

export function useStore() {
  return useSyncExternalStore(store.subscribe, store.getState);
}
Enter fullscreen mode Exit fullscreen mode

For this example I used React's built-in useSyncExternalStore hook, which lets you subscribe a component to an external source of state — our Flux store, in this case. When the store reports a change, React reads the current state and re-renders the component if needed.

But useSyncExternalStore is not part of Flux here. It is merely the link between the Flux store and React.

To get a better feel for how that link works, let's try writing our own useStore using simpler React hooks — useEffect and useState.

// useStore.js
import { useEffect, useState } from "react";
import { store } from "./myStore";

export function useStore() {
  const [state, setState] = useState(store.getState);

  useEffect(() => {
    return store.subscribe(() => {
      setState(store.getState());
    });
  }, []);

  return state;
}
Enter fullscreen mode Exit fullscreen mode

Something fairly simple happens here: when the component is created, we subscribe to the store. When the store changes, its subscribe calls our callback, and the callback passes the new state into setState. Changing local state makes React re-render, and the hook returns the store's current state. That is exactly what useSyncExternalStore does.

Note useState(store.getState): the function is passed without being called — it is a lazy initializer. React will call it once on the first render and put the result into state, rather than the function itself.

Flux itself still knows nothing about React. It only provides two mechanisms: read the state (getState) and subscribe to its changes (subscribe). Everything that happens after that is the React integration's job.

So we already have a working solution for managing data without losing control at any step. We can update data now, but the current implementation has a problem: store.setState merges any keys you hand it into the state, and there is no way to tell from the code which updates are even intended. This is where it is worth extending the architecture with the Dispatcher concept, narrowing updates down to a known dictionary of actions.

// dispatcher.js
const handlers = new Set(); // action handlers

// create the dispatcher
export const dispatcher = {
  register(handler) {
    handlers.add(handler);

    return () => handlers.delete(handler);
  },

  // notify is passed along so the handler decides when to announce the change
  dispatch(action, notify) {
    handlers.forEach((handler) => handler(action, notify));
  },
};
Enter fullscreen mode Exit fullscreen mode

Now let's add the dispatcher to our Flux:

// myStore.js
import { dispatcher } from "./dispatcher";

let state = {
  name: "Duncan",
  role: "knight",
};

const listeners = new Set();

// the supported updates are registered here — because this is where state lives
dispatcher.register((action, notify) => {
  switch (action.type) {
    case "HERO_SET_NAME":
      state = {
        ...state,
        name: action.payload,
      };
      break;

    case "HERO_SET_ROLE":
      state = {
        ...state,
        role: action.payload,
      };
      break;

    // an unknown action changes nothing and wakes nobody
    default:
      return;
  }

  notify(); // this calls the function that updates the subscribers
});

export const store = {
  getState() {
    return state;
  },

  // send an action
  dispatch(action) {
    // pass a function, not a call: the handler runs it after the change
    dispatcher.dispatch(action, () => {
      listeners.forEach((listener) => listener());
    });
  },

  subscribe(listener) {
    listeners.add(listener);

    return () => listeners.delete(listener);
  },
};
Enter fullscreen mode Exit fullscreen mode

Notice too that the store's method is now called dispatch rather than setState: it takes an action, not a slice of state, and the old name would only be confusing.

Updates are now described by two parameters: type says what we are changing, and payload carries the new data.

import { store } from "./myStore";

store.dispatch({
  type: "HERO_SET_NAME",
  payload: "Artorias",
});
Enter fullscreen mode Exit fullscreen mode

That is how I used this architecture, quite happily. Two small files, myStore.js and dispatcher.js, holding the entire one-way data flow, without a single line I did not know the purpose of.

The hero studies a book

Attempting a Library: Flux + createContext

I kept using Flux, but over time it grew, and I felt like trying to write my own npm library. I had never done it before, but it was a challenge I wanted to take on: by the end I might have something that could serve as an alternative to the managers we have now. And it was simply interesting.

So I started — rather clumsily at first — moving all the logic into a more convenient shape. In the earliest versions I decided to step away from the familiar Flux approach and try combining its capabilities with React: createContext and a provider component.

Let me start with a simple createContext example, one you have probably seen or written yourself:

// MyProvider.js
import { createContext, useState } from "react";

const MyContext = createContext(null);

function MyProvider({ children }) {
  const [hero, setHero] = useState({
    name: "Duncan",
    role: "knight",
  });

  return (
    <MyContext.Provider value={{ hero, setHero }}>
      {children}
    </MyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

This lets you keep data inside the context and reach it directly from any child component:

function App() {
  return (
    <MyProvider>
      <Header />
      <Sidebar />
      <Profile />
    </MyProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Profile.js
import { useContext } from "react";

function Profile() {
  const { hero, setHero } = useContext(MyContext);

  return (
    <>
      <h1>Hello, {hero.name}</h1>

      <button onClick={() => setHero({ name: "Artorias", role: "knight" })}>
        Change name
      </button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

It all works — the data travels deep into the tree, which is precisely what context was made for. But that does not make it a state manager, and here is the main reason.

Context re-renders every consumer on any change. React cannot subscribe to a part of a value: it compares value as a whole. If a component called useContext, it re-renders on every context update — even if it reads a single field from there that did not change.

What is more, in our example value={{ hero, setHero }} is a new object on every render of the provider. Which means the re-render goes out to every consumer always, whether hero changed or not.

And memo does not save you here: it compares props, while useContext reads its value bypassing props entirely. A component wrapped in memo still re-renders if the context it reads was updated.

The usual workaround is slicing the data across several contexts so components subscribe to different ones. That works while you have two or three; beyond that it turns into a tree of providers where every little thing gets one more.

So we are left with exactly the problems we already solved in our own Flux implementation: reading state independently of a component, subscribing only to the part you need, and changing it through a single interface. Let's now try to combine Flux and useContext:

// MyProvider.js
import { useRef } from "react";

let state = {
  name: "Duncan",
  role: "knight",
};

function MyProvider({ children }) {
  const subscribers = useRef(new Set());

  const setState = (newState) => {
    state = {
      ...state,
      ...newState,
    };

    subscribers.current.forEach((callback) => callback());
  };

  return (
    <MyContext.Provider
      value={{
        getState: () => state,
        setState,
        subscribe: (callback) => {
          subscribers.current.add(callback);

          return () => subscribers.current.delete(callback);
        },
      }}
    >
      {children}
    </MyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

It plugs in the same way as the ordinary provider above.

Although the initial data is still hardcoded into the module — how a user of the library would supply their own is unclear. Let's keep that question in mind; we will come back to it soon.

But from here on the difference begins. Components no longer read data straight out of the context — the context only holds getState, setState and subscribe, that is, the store's interface rather than the state itself. A component receives data through a subscription. Let's build the useStore hook again with useSyncExternalStore, but taking what it needs from the context:

// useStore.js
import { useContext, useSyncExternalStore } from "react";

export function useStore() {
  const { getState, subscribe } = useContext(MyContext);

  return useSyncExternalStore(subscribe, getState);
}
Enter fullscreen mode Exit fullscreen mode

And the long-awaited usage:

// Profile.js
import { useContext } from "react";
import { useStore } from "./useStore";

function Profile() {
  const { name } = useStore(); // a reactive variable
  const { setState } = useContext(MyContext); // a way to change the data

  return (
    <>
      <h1>Hello, {name}</h1>

      <button onClick={() => setState({ name: "Artorias" })}>
        Change name
      </button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The context value no longer changes when the data is updated — what changes is the state next to it, and components learn about it through subscribe. That problem of re-rendering every consumer is gone. And this is already a decent implementation that works.

Where This Construction Falls Apart

It looks functional. But take a close look at where things live:

let state = { ... };                      // outside the component

function MyProvider({ children }) {
  const subscribers = useRef(new Set());  // inside the component
Enter fullscreen mode Exit fullscreen mode

state is declared at module level: there is a single one for the whole application, and it lives as long as the provider does. subscribers is created with useRef: each instance of MyProvider gets its own, and it dies along with that instance.

While there is a single provider this goes unnoticed, but mount two:

<MyProvider>
  <Profile />    {/* one set of subscribers */}
</MyProvider>

<MyProvider>
  <Sidebar />    {/* a different set of subscribers */}
</MyProvider>
Enter fullscreen mode Exit fullscreen mode

Both see the same data — state is shared. But a setState called inside the first provider will only wake its own subscribers. The Sidebar in the second one never hears about the change and stays on screen with stale data.

The mistake is not in either half. The mistake is that the two halves of one store live in different places with different lifetimes. A store is not "data" plus "subscribers" on the side. A store is data together with the subscribers to it. They have to be born together and die together.

There is a second side to the same problem, less visible: the object in value is recreated on every render of the provider, which means subscribe is a new function every time, and useSyncExternalStore is forced to unsubscribe and subscribe again.

Moving the State Inside

The fix suggests itself: since the subscribers live inside the provider, let the state live there too. That also answers the question I have been dodging — how does a user of the library supply their own initial data? Through a prop:

// MyProvider.js
import { useRef } from "react";

function MyProvider({ children, initialState }) {
  const stateRef = useRef(initialState);
  const subscribers = useRef(new Set());

  const setState = (newState) => {
    stateRef.current = {
      ...stateRef.current,
      ...newState,
    };

    subscribers.current.forEach((callback) => callback());
  };

  return (
    <MyContext.Provider
      value={{
        getState: () => stateRef.current,
        setState,
        subscribe: (callback) => {
          subscribers.current.add(callback);

          return () => subscribers.current.delete(callback);
        },
      }}
    >
      {children}
    </MyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

There is one subtlety that matters here. You cannot simply reassign the parameter:

function MyProvider({ children, state }) {
  const setState = (newState) => {
    state = { ...state, ...newState }; // the edit lives until the next render
  };
Enter fullscreen mode Exit fullscreen mode

React calls the component again on every render, and the parameter is read afresh from props — everything we wrote into it disappears. We need a place that survives renders, and that is useRef.

Now everything is honest: we pass our own data in from outside, and the provider has become independent.

const state = {
  name: "Duncan",
  role: "knight",
};

<MyProvider initialState={state}>
  <Profile />
</MyProvider>;
Enter fullscreen mode Exit fullscreen mode

This approach is what I built my first two versions of the library on.

The hero tries to merge two technologies

Moving to Factories

It works. But look at what we ended up with: the entire store lives inside a React component.

You cannot create it without React — you need a renderer. You cannot check it in a test without mounting a component tree. You cannot use it in an event handler outside React, in a worker, or on the server. The lifetime of the data is tied to the lifetime of a piece of interface, even though the data has nothing to do with the interface.

And the problem of the object in value being recreated on every render has not gone anywhere either.

Yet the only piece of React we actually need here is useRef, so that something survives a render. But if the store does not live inside a component, there is nothing to survive: a plain variable in a closure already lives exactly as long as we need.

Let's move everything out of the component into an ordinary function:

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

  return {
    getState: () => state,

    setState: (next) => {
      state = { ...state, ...next };
      listeners.forEach((listener) => listener());
    },

    subscribe: (listener) => {
      listeners.add(listener);

      return () => listeners.delete(listener);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

A function like this is called a factory. This way of writing code is described well in the book "Learning JavaScript Design Patterns", which — ironically — I came across later than I ran into the concept itself. Everything becomes simple: call it, get a store. Its own state, its own subscribers, a stable reference to an object that does not change from render to render. Need a second, independent one? Call it again. It is a little reminiscent of Flux, except now it feels reusable.

And this is where I eventually arrived.

What the Closure Buys Us

The factory above solves the lifetime problem, but so far it does exactly what the old store did. What it has now, though, is a place to put everything else: state and listeners are just variables inside a function call, and you can declare as many others next to them as you like.

Let's start with the thing this was all about. Remember the complaint about lifting state up — the whole subtree re-renders, including the components that are not involved? Our store still behaves the same way: listeners is a single set, and setState wakes absolutely every subscriber, even if only one field changed.

To wake only the right ones, the set of subscribers has to be laid out by key:

function createStore(initial) {
  let state = initial;
  const listeners = new Map(); // key -> the set of subscribers to it

  const notify = (keys) => {
    // first gather who to call: a subscriber watching several keys
    // should get one notification, not one per key
    const called = new Set();

    keys.forEach((key) => {
      listeners.get(key)?.forEach((listener) => called.add(listener));
    });
    listeners.get("*")?.forEach((listener) => called.add(listener));

    called.forEach((listener) => listener(state));
  };

  const get = (key) => (key === undefined ? state : state[key]);

  const set = (next) => {
    const prev = state;
    state = { ...state, ...next };

    // notify only for the keys that actually changed
    const changed = Object.keys(next).filter((key) => prev[key] !== state[key]);
    if (changed.length) notify(changed);
  };

  const subscribe = (listener, keys = ["*"]) => {
    keys.forEach((key) => {
      if (!listeners.has(key)) listeners.set(key, new Set());
      listeners.get(key).add(listener);
    });

    return () => keys.forEach((key) => listeners.get(key)?.delete(listener));
  };

  return { get, set, subscribe };
}
Enter fullscreen mode Exit fullscreen mode

Not much changed — Set became Map — but the behaviour is now different:

const store = createStore({ name: "Duncan", role: "knight", level: 3 });

store.subscribe(onName, ["name"]);
store.subscribe(onLevel, ["level"]);

store.set({ name: "Artorias" }); // onName was called, onLevel was not
store.set({ name: "Artorias" }); // same value: nobody was called
Enter fullscreen mode Exit fullscreen mode

Three things at once. A component reading level will not re-render when the name changes: the experience bar does not flicker because the player renamed their hero. Writing the same value wakes nobody at all. And a subscriber watching both name and level gets one notification when both keys update rather than two — which is exactly why notify builds that intermediate called set.

Actions Live There Too

The second thing that was missing. Remember the dispatcher? It worked, but it lived apart from the store and knew about state through a module-level variable — the very scope problem we are curing here.

Since a factory is a function, we can hand it not only the initial state but also a description of the actions. And it will call that description, passing get and set inside:

function createStore({ state: initial, acts }) {
  let state = initial;
  const listeners = new Map();

  // ... notify, get, set, subscribe — as above ...

  // actions are born right here and receive get/set through the closure
  const actions = acts ? acts(get, set) : {};

  return { get, set, subscribe, acts: actions };
}
Enter fullscreen mode Exit fullscreen mode

This is pleasant to use: nothing has to be wired up by hand, an action already knows its store. And so we arrive at the final implementation:

const store = createStore({
  state: { level: 1 },

  acts: (get, set) => ({
    levelUp() {
      set({ level: get("level") + 1 });
    },

    boost(n) {
      set({ level: get("level") + n });
    },
  }),
});

store.acts.levelUp();
store.acts.boost(10);

store.get("level"); // 12
Enter fullscreen mode Exit fullscreen mode

Compare that with the dispatcher: there we had an action object, a switch over a string type, and a separate file that somehow had to reach the state. Here they are ordinary functions, and the state is available to them by virtue of where they were born.

And notice what we did not write. No registration, no list of types, no wiring between files. Everything the store needs is created in a single call and stays inside.

The hero sees a Factory in the distance

createNexus

Now let's get right up to the library itself. Everything we have written so far is a simplified, if detailed, retelling of what happens inside it. The factory there is called createNexus, and using it looks almost exactly like our last example:

import { createNexus } from "nexus-state";

const nexus = createNexus({
  state: { level: 1 },

  acts: (get, set) => ({
    levelUp() {
      set({ level: get("level") + 1 });
    },
  }),
});

nexus.acts.levelUp();
nexus.get("level"); // 2
Enter fullscreen mode Exit fullscreen mode

What the Factory Returns

The call hands back six things — and all of them are closures over one and the same call, exactly as in our teaching version:

const { get, set, subscribe, reset, middleware, acts } = nexus;
Enter fullscreen mode Exit fullscreen mode

get — read the whole state or a single key.

nexus.get(); // { level: 2 }
nexus.get("level"); // 2
Enter fullscreen mode Exit fullscreen mode

set — write part of the state, either as an object or as a function of the current one. A second argument lets you name the source of the update; more on that shortly.

nexus.set({ level: 5 });
nexus.set((state) => ({ level: state.level + 1 }));
Enter fullscreen mode Exit fullscreen mode

subscribe — subscribe to changes of the chosen keys. Returns an unsubscribe function. The keys are given explicitly, and ["*"] means "all of them".

const off = nexus.subscribe((state) => render(state), ["level"]);
Enter fullscreen mode Exit fullscreen mode

reset — return the state to its initial value: entirely or key by key.

nexus.reset("level"); // the level only
nexus.reset(); // the whole state
Enter fullscreen mode Exit fullscreen mode

middleware — wedge yourself between "I want to update" and "it updated". It sees the previous and the next state along with the source. It too returns a function that removes it.

const stop = nexus.middleware((prev, next, context) => {
  console.log(context?.source, prev.level, "=>", next.level);
});
Enter fullscreen mode Exit fullscreen mode

acts — your actions, the ones from the config.

nexus.acts.levelUp();
Enter fullscreen mode Exit fullscreen mode

What Lies Inside

So far we have been looking at the library from the outside. Let's peek under the hood — this is no longer the API but how it is built: those same variables in a closure, only now there are more of them than in our teaching factory.

function createNexus(options) {
  const frozenInitial = snapshot(options.state); // an untouched copy for reset
  let state = snapshot(options.state); // the live state

  const listeners = new Map(); // subscribers by key
  const localMiddleware = []; // update interceptors

  let batchDepth = 0; // how deeply actions are nested
  const pendingKeys = new Set(); // what piled up during a batch
  let currentActionName; // which action is running right now

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Every line here is an answer to some need, and each is possible only because the store has a space of its own.

frozenInitial — so reset returns to the initial state rather than whatever happened to be left over. localMiddleware — so there is somewhere to keep the interceptors. batchDepth together with pendingKeys — so that an action making five set calls in a row wakes the subscribers once instead of five times.

That last one deserves a word. Actions in createNexus are wrapped: while an action runs, the counter is raised, the changes pile up, and a single notification goes out at the end. That is also where currentActionName comes from: an update made inside an action is automatically tagged with that action's name.

And this is where the thing that sets nexus-state apart shows up.

Where the Update Came From

Every update can have a source, and the store remembers it:

nexus.set({ hero }, "server");
// the extended form, with metadata
nexus.set({ theme }, { source: "storage", meta: { restoredAt: Date.now() } });
Enter fullscreen mode Exit fullscreen mode

An explicitly given source reaches both the subscribers and the middleware:

nexus.subscribe(
  (state, context) => {
    console.log("changed by", context?.source);
  },
  ["hero"],
);
Enter fullscreen mode Exit fullscreen mode

It sounds like a trifle until you hit a task where you cannot do without it.

The classic one is persisting state. The store writes data to localStorage on change and reads it back at startup. But reading is also a set, which means the subscriber that does the writing fires and immediately writes back what it has just read. An echo. People usually fight it with flags like isHydrating, which you must remember to raise and lower.

That brings us to persist. If an update has an origin, no flag is needed: persist simply does not react to what came from itself. From the outside it looks like this:

import { createNexus, persist } from "nexus-state";

const nexus = createNexus({
  state: { name: "Duncan", role: "knight", level: 1 },
});

// persist the name and the class only; let the level live in memory alone
persist(nexus, { key: "hero", include: ["name", "role"] });
Enter fullscreen mode Exit fullscreen mode

One line — and the state survives a page reload. No flags, no checks needed.

persist itself is built on an ordinary subscription — it simply looks at where an update came from. And for the cases where you need to step in before the write, localMiddleware sits in the closure. Middleware is a function called between "I want to update" and "it updated", and it sees both states along with the source:

const stop = nexus.middleware((prev, next, context) => {
  console.log(context?.source, prev.level, "=>", next.level);
});

nexus.set({ level: 10 }, "server"); // server 1 => 10

stop(); // middleware can be removed
Enter fullscreen mode Exit fullscreen mode

If it returns a state, that state replaces the one that was about to be written — and by returning prev you can cancel the update altogether.

The adapter for Redux DevTools is built just as simply, on a subscription: the panel shows not a faceless SET_STATE but the name of the action, because that name was set automatically — by that currentActionName from the factory's closure.

import { devtools } from "nexus-state/devtools";

devtools(nexus, { name: "hero" });
Enter fullscreen mode Exit fullscreen mode

Back to React

The core is ready — and, like Flux at the very beginning, it knows nothing about React. But this article started with a React problem, and it is time to close it.

The hooks live behind a separate entry point:

import { createReactNexus } from "nexus-state/react";

const nexus = createReactNexus({
  state: { name: "Duncan", role: "knight" },

  acts: (get, set) => ({
    rename(name) {
      set({ name });
    },
  }),
});
Enter fullscreen mode Exit fullscreen mode

createReactNexus is the same factory. Inside, it calls createNexus and adds three hooks to the result, closed over that same store:

return {
  ...nexus, // everything createNexus returns
  use,
  useSelector,
  useRerender,
};
Enter fullscreen mode Exit fullscreen mode

So it has everything the core had — get, set, subscribe, reset, acts, middleware — plus the React part.

And now the very example we started with:

function Header() {
  const name = nexus.use("name");

  return <h1>Hello, {name}</h1>;
}

function Sidebar() {
  const name = nexus.use("name");

  return <aside>{name}</aside>;
}

function Profile() {
  return (
    <button onClick={() => nexus.acts.rename("Artorias")}>Change name</button>
  );
}
Enter fullscreen mode Exit fullscreen mode
function App() {
  return (
    <>
      <Header />
      <Sidebar />
      <Profile />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice what is missing here. There is no provider. The tree is not wrapped in anything: the store exists on its own, and components subscribe to it directly. Nothing has to be lifted up or threaded down through props.

And Profile, which does not read the name, will not re-render when it changes — it is simply not subscribed to anything. use("name") is a subscription to exactly one key, that same Map from the section about closures.

When you need something derived from the state rather than a field, there is useSelector:

function Greeting() {
  const fullName = nexus.useSelector((state) => `${state.name} ${state.role}`);

  return <span>{fullName}</span>;
}
Enter fullscreen mode Exit fullscreen mode

The keys the selector reads are tracked automatically — there is no dependency list to pass.

Why It Is a Separate Import

createNexus lives in nexus-state, and createReactNexus in nexus-state/react. The split is not cosmetic.

The core does not import React at all: the package has zero dependencies, and createNexus works in any environment — in Node, in a worker, in a test without a renderer. React is declared as an optional peer and is only needed by those who reach for the second entry point.

This is exactly the thought everything started with — "Flux does not know React exists" — only carried all the way into how the package is built. The React hooks are not part of the store here but a layer on top: createReactNexus calls createNexus and adds three functions that are aware of React.

The circle is closed. That task from the prologue — showing a single name in three components — is solved in three lines, and I know what happens in every one of them.

What Did Not Make It In

The library ships createActs, which lets you spread actions across files without losing either the types or access to one another. There are recipes for Immer and for SSR in Next.js.

And separately, the types. I promised not to touch typing and I did not, but I will say one thing: state and actions are inferred from the config, so you hardly ever write generics by hand. get("level") returns a number, not any, because the library already knows the shape of your state. Deep typing is one of the things that make this library genuinely pleasant to use.

Also, while writing this article I got curious about checking myself against actual numbers. The closest thing by construction is zustand: the same observer, the same idea of selectors. So that is what I compared against, version 5.0.15.

100 components over 50 keys, 20 updates — each changing a single key

                   selector runs     renders
  zustand 5.0.15            2120          40
  nexus-state                200          40
Enter fullscreen mode Exit fullscreen mode

The render counts are equal — and that matters: zustand does not re-render anything extra, it computes extra. The selector runs for every subscriber, and comparing the result is what cuts off the render. A per-key subscription simply never reaches those who are not involved.

But it has a price, and on small stores it does not pay for itself:

20,000 updates, subscribers without React

   keys    components     zustand   nexus-state
      1             1      3.5 ms        5.2 ms   zustand faster ×1.5
      3             5      3.4 ms        4.6 ms   zustand faster ×1.4
      5            10      4.6 ms        4.8 ms   a tie
     10            20      6.3 ms        5.0 ms   nexus faster ×1.3
     50           100     32.3 ms        8.5 ms   nexus faster ×3.8
    100           500    155.3 ms       13.1 ms   nexus faster ×11.9
Enter fullscreen mode Exit fullscreen mode

The turning point is somewhere around ten keys. Below that, laying subscribers out by key costs more than walking a short list in full.

The hero creates a new technology

Conclusion

The main thing I took away: there was no magic. Everything that seemed unfathomable to me in other people's state managers fits into a few dozen lines — a variable, a collection of subscribers, and a function that calls them. The rest is detail, each piece solving a concrete, understandable problem. Though I will admit that writing a library was far from easy and took a good deal of time.

I will add this as well: naming things matters more than the number of features. The most valuable part of nexus-state is not its features but the fact that every update has a source. That single decision removed an entire class of problems that are usually plugged with flags.

When I set out to write the library, what I wanted above all was to understand how state managers work. I hope this article managed to show that.

The library is on npm, the sources are on GitHub. There are also docs to read.

If you have read this far — thank you, and may your Nexus always be within reach.

The hero rests, pleased with the work done

Top comments (2)

Collapse
 
respect17 profile image
Kudzai Murimi •

The walk from Flux to Context to factory closures is one of the clearer explanations I've read of why state managers are shaped the way they are, not just how to use one. The per key Map of subscribers, plus tagging updates with a source so persist() can skip its own writes without an isHydrating flag, is a genuinely elegant fix for a problem most libraries just patch around.

Collapse
 
voodoofugu profile image
Georg •

Thank you — "why they're shaped that way" was exactly what I was after, so good to hear it landed.🙏