In modern JavaScript, the ES module system provides a powerful and intuitive way to structure code. A fascinating side effect of this system is how it handles objects and arrays: when imported into different modules, they are not new instances but shared references. This effectively allows for the creation of simple, global objects.
This leads to a compelling question: if we can easily create globally shared objects with the standard module system, why do we rely on dedicated state management libraries like Zustand or Redux in React?
The Singleton Power of JavaScript Modules
First, let's look at how modules share data. Due to the module caching mechanism, a module is only ever executed once. Any variables, especially objects or arrays, declared in that module become singletons.
Consider a shared module, store.js:
// store.js
// This array is initialized only once.
export const sharedArray = [];
Now, let's import and modify this array in ModuleA.js:
// ModuleA.js
import { sharedArray } from "./store.js";
// This mutation affects the single, shared instance of the array.
sharedArray.push(1);
console.log("From ModuleA:", sharedArray); // Outputs: [1]
When another module, ModuleB.js, imports the same array, it sees the changes made by ModuleA.
// ModuleB.js
import { sharedArray } from "./store.js";
// The changes made in ModuleA are reflected here.
console.log("From ModuleB:", sharedArray); // Outputs: [1]
This happens because both modules import a reference to the exact same array in memory. This is a simple yet effective way to share state across different parts of a JavaScript application.
The Missing Piece: Reactivity
So, why can't we just use this technique for managing state in a React application? The problem isn't about sharing state—it's about notifying React when that state changes.
React's rendering is tied to its own state management lifecycle. A component re-renders primarily when its state (created with hooks like useState) or its props change.
When you mutate an imported object or array directly, you are doing so outside of React's purview. React has no built-in mechanism to "watch" a plain JavaScript variable from a module. The data changes, but the UI doesn't know it needs to update.
This is where dedicated state management libraries come in. They solve this exact problem.
How State Libraries Bridge the Gap
Global state libraries like Zustand work by combining a simple, external store (like our module example) with a subscription system.
The Store: They hold the application state in a plain JavaScript object, outside of any specific React component.
The Subscription: They provide a way for React components to "subscribe" to changes in that store.
The Notification: When the state is updated, the library notifies all subscribed components, which then triggers a re-render.
Modern libraries often use the useSyncExternalStore hook, which is designed specifically for this purpose—to safely subscribe React components to an external data source.
A Look Inside: A Simplified Zustand
Let's demystify this with a simplified implementation of a Zustand-like store.
First, we need a createStore function. This factory creates our singleton store, which holds the state, manages updates, and maintains a list of subscribers.
// This function creates our state management store.
const createStore = (initializer) => {
let state;
const listeners = new Set(); // A set to hold our subscriber functions.
// The 'set' function updates the state and notifies all listeners.
const setState = (updater) => {
// An updater can be a new state object or a function (e.g., state => ({...})).
const newState = typeof updater === 'function' ? updater(state) : updater;
state = { ...state, ...newState };
// Notify all subscribed components of the change.
listeners.forEach((listener) => listener());
};
// The initial state is created by calling the user's initializer function.
state = initializer(setState);
return {
getState: () => state,
setState,
subscribe: (listener) => {
listeners.add(listener);
// Return an unsubscribe function for cleanup.
return () => listeners.delete(listener);
},
};
};
Next, we use this factory to create our actual store instance. This should only be done once.
JavaScript
// Create the store as a singleton and export it.
export const myStore = createStore((set) => ({
count: 0,
// Actions that update state call the 'set' function.
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}));
Finally, we create a custom hook that uses useSyncExternalStore to connect a component to our store.
JavaScript
import { useSyncExternalStore } from "react";
import { myStore } from "./store"; // Import our singleton store
// This custom hook makes it easy to use the store in any component.
export const useMyStore = (selector) => {
return useSyncExternalStore(
myStore.subscribe,
() => selector(myStore.getState()),
() => selector(myStore.getState()) // Optional: for server-side rendering
);
};
By calling useMyStore(state => state.count) in a component, we are telling React to subscribe to myStore, extract the count value, and re-render whenever the store notifies it of a change.
Top comments (1)
Awesome article, very informative!