DEV Community

loading...
Cover image for Hacking React Hooks: Shared Global State

Hacking React Hooks: Shared Global State

bytebodger profile image Adam Nathaniel Davis Updated on ・7 min read

I'm going to show a dead-simple, possibly "hacky", technique for sharing global state and stateful logic between functional components with Hooks.

The Problem

I've lost count of how many times I've heard, or read, that Hooks can be used to "share state between components". It feels to me like this has become a de facto mantra of the React/Hooks crowd. And yet, every time I've tried to confirm this mythical capability with working real-life code, the results have been... underwhelming.

It's not that you can't share state with Hooks. It's just that many of the proposed methods either

  1. Leverage the same old techniques that we could always use in class-based components (with the same drawbacks),

  2. Or they veer off into complex-and-abstract solutions that are obtuse and potentially brittle.

In the "same story, different day" category, Hooks have excellent support for the Context API. And this can certainly be extremely useful. But the Context API can't share state between two sibling components unless the state is saved higher up the chain.

And of course, we can "share" state by passing it down through props. But we've always been able to do that, it's subject to the same hierarchy limitations as the Context API, and most of us hate it.

In the "new solutions" category, I've already seen too many proposed approaches that leverage useReducer(), useCallback(), useEffect(), Higher Order Hooks, and the powdered tailbone of a virgin pterodactyl.

The Goal

I want to have a single function/Hook that can keep its own state, share that state with anyone who wants it, and pass render updates to any components that are reading that state. I want that component to be accessible from anywhere in the app. And finally, I need for any updates to its state to be controlled through a single interface.

Oh... and I want the implementation to be ridiculously simple.

Am I asking too much? I don't think so. But it's amazing how many wildly-different approaches you can find to this problem across the interwebs.

A "Default" Approach With Custom Hooks

We have three siblings - Larry, Curly, and Moe. We also have Curly's child - Curly Jr. Between the four of them, each of them needs to display the current value of the counter. In other words, the value of the counter needs to be a shared value.

Furthermore, Larry, Curly, Moe, and Curly Jr all have different responsibilities for updating the counter. Whenever an update occurs, the value needs to be reflected with each person. (A live, working example of following code can be found here: https://stackblitz.com/edit/custom-hooks-shared-state-bfadtp)

[Disclaimer: As you look at these examples, you might be thinking that it would be optimal to achieve these results - in this example - by passing state through props. Or even, by using the (awesome) Context API. And I would agree with you. But it's hard to illustrate the potential benefits of a global state management solution if I have to drop you right into the middle of my Big Hairy App. So I'm obviously using an extremely simplified scenario to illustrate how this approach might work on a far larger app. I trust that you can extrapolate from these examples...]

// index.js
const App = () => {
  return (
    <>
      <Larry />
      <Curly />
      <Moe />
    </>
  );
};

// use.counter.js
export default function useCounter() {
  const [count, setCount] = useState(0);
  const decrement = () => setCount(count - 1);
  const increment = () => setCount(count + 1);
  const invert = () => setCount(count * -1);
  const reset = () => setCount(0);
  return {
    count,
    decrement,
    increment,
    invert,
    reset
  };
}

// curly.jr.js
export default function CurlyJr() {
  const counter = useCounter();
  return (
    <div style={{ marginBottom: 20, marginLeft: 150 }}>
      <div>Curly Jr: {counter.count}</div>
      <div>
        <button onClick={counter.invert}>Invert</button>
      </div>
    </div>
  );
}

// curly.js
export default function Curly() {
  const counter = useCounter();
  return (
    <div style={{ marginBottom: 20 }}>
      <div style={{ float: "left" }}>
        <div>Curly: {counter.count}</div>
        <div>
          <button onClick={counter.decrement}>Decrement</button>
        </div>
      </div>
      <CurlyJr />
    </div>
  );
}

// larry.js
export default function Larry() {
  const counter = useCounter();
  return (
    <div style={{ marginBottom: 20 }}>
      <div>Larry: {counter.count}</div>
      <div>
        <button onClick={counter.increment}>Increment</button>
      </div>
    </div>
  );
}

// moe.js
export default function Moe() {
  const counter = useCounter();
  return (
    <div style={{ clear: "both" }}>
      <div>Moe: {counter.count}</div>
      <div>
        <button onClick={counter.reset}>Reset</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We have a custom Hook - useCounter(). useCounter() has its own state to track the value of count. It also has its own functions to decrement(), increment(), invert(), and reset() the value of count.

Larry, Curly, Moe, and Curly Jr all use the custom Hook useCounter(). They all display the value of count. And they each have their own button that is intended to either decrement(), increment(), invert(), or reset() the count variable.

If you load up this example in the StackBlitz link above, you'll see that this code... doesn't work. Everyone is using the same custom Hook. But they are not getting the same global value.

When you click on Larry's "Increment" button, only his counter increments. The others are unchanged. When you click on Curly's "Decrement" button, only his counter decrements. The others are unchanged.

Why does this happen? Well, the Hooks docs are pretty clear about how this works:

Do two components using the same Hook share state? No. Custom Hooks are a mechanism to reuse stateful logic (such as setting up a subscription and remembering the current value), but every time you use a custom Hook, all state and effects inside of it are fully isolated.

So a custom Hook is, by default, designed to share stateful logic, but it doesn't directly share state. Hmmm... That's incredibly unhelpful.

The docs go on to further explain that:

Each call to a Hook gets isolated state.

In other words, even though Larry, Curly, Moe, and Curly Jr are all calling the same Hook - useCounter(), each of those calls results in a fresh copy of count. So when, for example, Larry updates count with increment(), Curly, Moe, and Curly Jr are all oblivious to the fact - because their isolated versions of count have not been updated at all.

Global State With A Single Hook Instance

It's not enough for Larry, Curly, Moe, and Curly Jr to all use the same custom Hook. If they're going to truly share state, then they need to also share the same call to that custom Hook. It won't work for them all to create their own call to useCounter(), because that will spawn four separate instances of useCounter()'s state.

But how do we do that?

(A working example of the following code can be seen here: https://stackblitz.com/edit/custom-hooks-shared-state)

// global.js
export default {};

// index.js
const App = () => {
  global.counter = useCounter();
  return (
    <>
      <Larry />
      <Curly />
      <Moe />
    </>
  );
};

// use.counter.js
export default function useCounter() {
  const [count, setCount] = useState(0);
  const decrement = () => setCount(count - 1);
  const increment = () => setCount(count + 1);
  const invert = () => setCount(count * -1);
  const reset = () => setCount(0);
  return {
    count,
    decrement,
    increment,
    invert,
    reset
  };
}

// curly.jr.js
export default function CurlyJr() {
  return (
    <div style={{ marginBottom: 20, marginLeft: 150 }}>
      <div>Curly Jr: {global.counter.count}</div>
      <div>
        <button onClick={global.counter.invert}>Invert</button>
      </div>
    </div>
  );
}

// curly.js
export default function Curly() {
  const decrement = () => {
    global.counter.count = global.counter.count - 1;
  };
  return (
    <div style={{ marginBottom: 20 }}>
      <div style={{ float: "left" }}>
        <div>Curly: {global.counter.count}</div>
        <div>
          <button onClick={decrement}>Decrement</button>
        </div>
      </div>
      <CurlyJr />
    </div>
  );
}

// larry.js
export default function Larry() {
  return (
    <div style={{ marginBottom: 20 }}>
      <div>Larry: {global.counter.count}</div>
      <div>
        <button onClick={global.counter.increment}>Increment</button>
      </div>
    </div>
  );
}

// moe.js
export default function Moe() {
  return (
    <div style={{ clear: "both" }}>
      <div>Moe: {global.counter.count}</div>
      <div>
        <button onClick={global.counter.reset}>Reset</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this revised version, Larry, Curly, Moe, and Curly Jr all have access to the truly-global state variable count. When any single person performs an action to update count, the change is displayed on all the other people.

When Larry's "Increment" button is clicked, the change is seen on everyone. The same goes for Curly Jr's "Invert" button and Moe's "Reset" button.

Also notice that Larry, Curly, Moe, and Curly Jr are not even importing or directly calling useCounter() at all. A single instance of useCounter() was loaded into a simple JavaScript object (global) inside <App>.

Once we have a reference to useCounter() sitting in the global object, Larry, Curly, Moe, and Curly Jr need only import that same global object to reference the state values and the functions made available through useCounter().

However, Curly's "Decrement" button doesn't work. Why is that??

Controlled Access to Global State

Well, Curly got lazy and tried to directly update the global variable without going through the useCounter() custom Hook (that's saved in the global object). Curly tried to get cute by simply doing:

global.counter.count = global.counter.count - 1;
Enter fullscreen mode Exit fullscreen mode

But that has no effect. It doesn't update the value in global.counter.count.

This is a tremendously good thing. It avoids the nightmare of having a global variable that can be updated directly from dozens of different places in the app. In this implementation, the count variable can only be updated in the useCounter() custom Hook.

This also means that useCounter() can control what update methods are exposed to the subscribers. So if we don't want other components to have the ability to increment() the count value, that's easy. We just don't return the increment() function inside useCounter().

The Verdict

To be completely honest, this approach feels really good to me. It's soooo much cleaner than using third-party NPM packages or global state management libraries. I really love the Context API (and the awesome support for it in Hooks), but that approach isn't always ideal when you want to truly share data in real time across all branches of an application hierarchy. And the protected nature of the useCounter() Hook means that we can control if or how state updates are made.

You may not be too keen on the idea of using that generic, plain-ol'-JavaScript object global as a place to cache the instance of useCounter(). It's possible to also cache that call into an <App> variable, that is then shared with its descendants via the Context API. However, I wouldn't necessarily recommend that approach.

Because if we're using the Context API at the top level of the application to store/pass the global object, then every update to the global object will trigger a re-render of the entire app. That's why, IMHO, it's best to keep that plain-ol'-JavaScript object global outside of the "regular" life-cycle of the React components.

Are there critical flaws in this approach? Maybe. What am I overlooking???

Discussion (6)

pic
Editor guide
Collapse
pedrorelvas profile image
pedroRelvas • Edited

Hi, I got a problem with this in typescript. If Moe component (or any of them) is a tsx file, on the "onClick={global.counter.reset}" the counter give me the following error: Property 'counter' does not exist on type '{}'.
What can I do to solve this? I've created an interface on the global file and in the component where I gave counter a "any" type but nothing helps... Thank you in advance. :)

Collapse
laurentsenta profile image
Laurent Senta

Hi Pedro,
typescript is pretty explicit, there is no counter field on the global object,

you have to declare the type so the compiler can do its job. Here is an example:
mariusschulz.com/blog/declaring-gl...

Collapse
bytebodger profile image
Adam Nathaniel Davis Author

I would need... something more tangible to help with specific code examples. Can you please provide a CodePen or StackBlitz??

Collapse
ptz0n profile image
Erik Eng

Won’t this approach always rerender all App components on each global state change? Let’s say that some components don’t care about that single state property.

Collapse
bytebodger profile image
Adam Nathaniel Davis Author • Edited

The short answer is "no" - sorta. One thing I'm still learning about - years after I originally started doing React - is that "re-render" does not always equal "bad". And just because the render cycle is invoked, doesn't necessarily mean that anything was changed, that the component was rebuilt, or that the operation was expensive.

If you have 10 layers in a hierarchy of functional components, and you make a change to the top layer at Level 1, it is true that all of the downstream components in levels 2-10 will be called again. This does not mean that all of the downstream components in levels 2-10 will be updated/rebuilt in any way.

This is from the React docs regarding the reconciliation process:

It is important to remember that the reconciliation algorithm is an implementation detail. React could rerender the whole app on every action; the end result would be the same. Just to be clear, rerender in this context means calling render for all components, it doesn’t mean React will unmount and remount them. It will only apply the differences following the rules stated in the previous sections.

I think part of the confusion comes from React's core nomenclature. We often talk about rendering and the rendering cycle. And of course, in class components, there is even a render() function. But I think this causes some confusion (I know it does for me). Because, in React, render() doesn't mean "reconstruct the display of this component every time this function is called." Instead, it means, "If this function has never before been called, then construct the display of this component. If this function is being called after the initial construction, then invoke the diffing algorithm to see if we need to reconstruct the display in any way. If we don't need to reconstruct the display, then do nothing."

From everything I've read on the subject, it seems that invoking the diffing algorithm is cheap. The expensive part happens when the diffing algorithm indicates that we need to reconstruct some portion of the display. I've even noticed this anecdotally, although I don't have any empirical performance tests to share that back up my observations.

Collapse
bytebodger profile image