State management is hard. It’s one of those things there are a lot of great solutions out there for in the Javascript library ocean, but none are a 1-size-fits-all, especially when it comes to approaching medium-to-large size apps with some semblance of sanity. I’d like to share some of the things we’ve learned here at Reonomy in our heroic rewrite of our old Angular 1.7 app to a new React one, and I’d like to particularly focus on how we found a way to get hooks and observables to play nice with each other (audience gasps). I know, a crazy thought.

The ideal world is a totally self-enclosed component

Isn’t it a beautiful thing when a component does one thing, does it well, can be placed anywhere, and manages only its own state? Maybe it even has its own backend API with its own CRUD definitions, neatly Typed. These are the kinds of components you reread your own GitHub pull request code for over and over, at the end of the day, accompanied by a glass of whiskey, while listening to Brahms. And you think to yourself “it could all be this simple…really”, and you almost believe yourself. Your dream is shattered when you then remember that the remaining 100 components that make up your app need to share state with some or many other parts of your app. This is where those beautiful simple-use-case examples from Redux/MobX/RXJS/YourFavoriteLibray start to lose their luster.

The real world is messy, experimental, and not self-enclosed

Any state management library, when following its own conventions, will at first appear to be a great solution for managing app state on a small scale. When we first started with the rebuild, most of the engineers at its inception had more experience with Angular, so RXJS presented itself as a good library for managing shared state (RXJS has a much bigger Angular community than React). It seems to have a higher learning curve than Redux but is leaner (and meaner), and very fast when you get it right. It ended up not being a bad idea, however we suffered from over-using it, and in many cases supplanting the Hooks available natively in React like useContext and useReducer, which could have done the job better. And though at first the app suffered a bit from an identity crisis (should I next() an observable or should I dispatch() to a Context?), we’ve since settled on a blend that makes sense.

“Shared state”, what a loaded term. In a land of a giant React component tree, this term typically falls into two categories:

  • Global state, available to any and all the apps children, created only once when the app mounts.
  • Cross-component state, available only to specific app children and is created only for children who need it when they mount.

What we found is that while Hooks are great for most cases, there are often times using observables can offer a much greater flexibility. The caveat is that to use observables in React, you need to extract their values using Hooks, which means being very careful how you do so.

The just-one-notch-less-than-ideal world is Global State

Global state is almost too good to be true. Everything is available to everyone, all the time. You want it, you always know where to reach for it, and you are (mostly) sure you’ll get the most up-to-date version of it. This starts to change as you move from a small app to a larger and larger one. Frontenders are accustomed to hearing the horror stories of the unmanageably bloated Redux STORE.
For things you know the whole app must know about always (think mostly user info, product subscription, authentication, access level, user created content, etc), it’s best to choose one pattern for that. As a simplified example:

class DropdownModel {
  public isExpanded$ = new BehaviorSubject<boolean>(false);
  public expand = () => this.isExpanded$.next(true);
  public collapse = () => this.isExpanded$.next(false);
}
export const dropdownModel = new DropdownModel();
function SomeComponent(){
  const { expand, collapse, isExpanded$ } = dropdownModel;
  const isExpanded = customStateExtractorHook(isExpanded$);
  // do something with your dropdown state
}

Using hooks without observables is certainly possible here too, by using the Context API to create Providers to give child components access to state. Here is what the above could look like using Context instead:

export const DropdownContext = React.createContext();

export function DropdownProvider({ children }){
  const [isExpanded, setIsExpanded] = React.useState(false);
  return (
    <DropdownContext.Provider value={{ isExpanded, setIsExpanded }}>
      {children}
    </DropdownContext.Provider>
  );
}

You can use it here:

function SomeComponent(){
  const { isExpanded } = React.useContext(DropdownContext);
  if (isExpanded) {
    return <DropdownContainer />;
  }
}

Or here:

function SomeOtherComponent(){
  const { setIsExpanded } = React.useContext(DropdownContext);
  return (
    <Button onClick={() => setIsExpanded(true)}>Open dropdown</Button>
  );
}

As long as they are wrapped by their provider:

<DropdownProvider>
  <SomeComponent />
  <SomeOtherComponent />
</DropdownProvider>

For React aficionados, the latter method is the obvious choice. In fact, we have been moving all our global observables over to using Context with one caveat: that the entire app truly depends on that state. Without being strict about that., it’s easy to fall into the endlessly nested provider problem:

<BrowserRouter>
  <AuthProvider>
    <ThemeProvider>
      <DropdownProvider>
        <OhWaitThisNewDeeplyNestedThingWeNeedProvider>
          <App />
        </OhWaitThisNewDeeplyNestedThingWeNeedProvider>
      </DropdownProvider>
    </ThemeProvider>
  </AuthProvider>
</BrowserRouter>

You can see how this would be tempting to keep piling on ad nauseum. There are ways to consolidate and prettify the wrapping, but ultimately I don’t think this was the original intention of Context. If some small piece of state is to be shared between two components deep within the app, are we then always forced to give that context to the entire app, and likewise risk adding unnecessary rerenders?

The real problem: isolated cross-component sharing

If we weren’t concerned with storage size or memory management, putting all state into one giant global handler would be appealing. But we know we can transcend this morass of hackery, put on our Good Coder hat, eat some vitamins, and try to make our fellow engineers’ lives better. Why create stores of data and subscriptions we may not use? This is where trying to come up with a single pattern is particularly tricky.

To illustrate why marrying oneself to hooks and context inheritance may not be a great idea, let me describe an interesting case…

Imagine your app is filled with all these components that are wrapped by their own Providers, some share the same single Provider, some are wrapped by different instances of the same Provider, etc. Everything’s going swimmingly, all these separate parts of the app communicate with each other, and all their state is cleaned up when unmounted. Beauteous. Now, we want to add a feature: an activity monitor. This new isolated component needs to listen for activity within each and every one of these nested, subnested, and uber-nested components. Er… how should we do that? Do we create a function we can pass down from global context and pollute every component where we need to invoke the event? We’ve already polluted our app with litterings of marketing analytics events that all these components shouldn’t care about! Do we need yet another littering?

Thankfully in our case, these events (mostly AJAX requests) were already being captured by observables, so it was as easy as plucking off and subscribing to the BehaviorSubjects we cared about. We didn’t have to touch any code inside the components that were invoking the events.

This is where RXJS shines. Create a single observable for anyone around your app to subscribe to regardless of the context they are wrapped in.

const INITIAL = {
  foo: false
};

const myData$ = new BehaviorSubject(INITIAL);

Expose it in a Hook so that subscriptions can be managed.

export function useMyData(){
  const [data, _setData] = React.useState(INITIAL);

  React.useEffect(() => {
    const subscription = myData$.subscribe(_setData);
    return () => subscription.unsubscribe();
  }, [_setData]);

  const setData = payload => myData$.next(payload);

  return [data, setData];
}

Components can use it like a regular useState hook:

function SomeSetterComponent(){
  const [_, setData] = useMyData();
  const handleClick = () => {
    setData({
      foo: true
    });
  }
  return (
    <Button onClick={handleClick}>Make a foo</Button>
  );
}

And components elsewhere will always stay up to date:

function SomeGetterComponent(){
  const [data] = useMyData();
  return (
    <p>I am {data.foo === false && 'not'} a foo.</p>
  );
}

How strictly you want to enforce access to modifying methods can all be handled in the hook. The data itself can be empty (or with some basic initial value) until the first user of this hook invokes the spawning of its contents. The important thing to remember is that with each usage of the hook, new subscriptions to the data are created, but the observable data itself is not destroyed. It’s created once and lives forever. It should be treated as a singleton, and should exist outside the React component lifecycle.

This is an important distinction to make when compared to any data that is created inside a Hook. Hook data is trashed when the hook unmounts. This distinction can lead to a lot of antipatterns and headaches and hard-to-debug code, mainly when trying to create observables inside the component lifecycle. This is why I stress using observables sparingly in combination with hooks. The ideal cases for using observables in React ended up falling into two categories for us:

Wrapping all AJAX requests to our API to enforce consistent auth and error handling (and getting some nice freebies like in-flight cancellations)
Small isolated state sharing where the entire app did not need to be wrapped in a new Provider and finding a way to wrap those isolated components was problematic.

To wrap up, we have found that these strategies have led to better performance, less rendering, code reduction, and ease of modularity. So if you are game to explore this most unlikely union, here are a few takeaways that may be of help. As always, happy coding!

  1. Don’t create observables inside hooks.
export function useMyData(){
  const myData$ = new BehaviorSubject('foo');
  const [data, _setData] = React.useState(() => myData$.value);

  React.useEffect(() => {
    const subscription = myData$.subscribe(_setData);
    return () => subscription.unsubscribe();
  }, [_setData]);

  const setData = payload => myData$.next(payload);

return [data, setData];
}

Read: only ever create observables once;
You don’t have control over their lifecycle and won’t be guaranteed that any outside subscribers will unsubscribe() responsibly.

  1. Don’t instantiate classes inside hooks (read: only ever create observables once). We use classes to house some of our global, never-dying, state and subscriptions, but this is a pattern we are moving away from. Nasty memory leaks due to immortal subscriptions can happen when hooks that create classes unmount. Plus, classes confuse both people and machines and go against the Hooks’ functional paradigm.
  2. Limit observable data to its most basic, minimum possible structure. Let the decorating, mutating, reducing, etc, happen in separate static stateless functions.
  3. Avoid side-effects inside observable chains. Have all .next() calls invoked from inside component state. Expect one result from one invocation. This means think twice any time you reach for the tap RXJS operator if it’s not just for debugging.
Unlock commercial real estate insights and opportunities with ease Start Searching

Related Posts