author avatar
By Adam DavisSenior Software Engineer

*Views, thoughts, and opinions expressed in this post belong solely to the author, and not necessarily to SemanticBits.

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), or
  2. 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  the following code can be found here.)

[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>
  );
}

 

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 a completely 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)

// 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>
  );
}

 

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;

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 so 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” lifecycle of the React components.