State of Bliss: Handle your state with React, Apollo, and Unstated


Meditating on React state

Apollo is the best solution for managing remote data with a client cache. But if you are already using Apollo (or another client cache like Relay), the question is: What should you do about the rest of your client state? React’s new context API (available in 16.3 or as a polyfill) has opened up new possibilities for dealing with app state. At OK GROW!, we’ve been trying to find that blissful state that combines the data from the server, component state, and the app state, which hasn’t always been smooth.

App state is tiny

I became interested in Redux after watching Dan Abramov’s seminal talk at ReactEurope. After I’d learned the basics of Redux, I began looking for information on “advanced Redux.” But that brought me face-to-face with the problem with Redux for Apollo users: Every tutorial I found turned out to be implementing a client cache. You would learn the three or so ways to do async with Redux and then implement those async actions to work with your Redux store. But Apollo’s client cache already does all of that. It has all of my loading and error states handled, it deals with optimistic updates, you name it. So what’s left to manage?

The answer, as it turns out, is very little for most apps. Most of it should just live in React components.

What about centralizing to Redux for better testability?

I agree that this makes testing very easy, but I have two problems with it:

  1. It breaks encapsulation. The whole point of having a component system is for encapsulation. React allows you to put state into your components so that when you are working elsewhere, you don’t have to think about what’s happening in that component. As soon as you try to separate that functionality, you have keep two things in your head, the component and the state (which is now probably in a separate file, so you need more buffers open). Not only that, but there is also risk that somebody else (never you!) would be tempted to grab that state from a different component.
  2. Removing state from components feels un-React-y. I’ve heard of developers making their entire app out of functional components with all state coming from Redux. Hey, I had a “let’s use all functional components!” phase too. But having a global store bypasses the top-down data flow and makes your app harder to reason about. Also, that’s a lot more code!

Some of this movement stems from the fact that testing components has been pretty awkward for a long time, but I think we can solve that using tools like React Test Renderer (more on this in a future post), or possibly a new tool like Cosmos.

There are apps that will really benefit from Redux, so I’m not saying not to use it. But in my own work, I have not been able to justify the overhead once Apollo took over remote data synchronization and caching.

What state is left to manage?

If you put most of your state into components and have a client cache, what’s left? Almost nothing. In one app, I had only a single global state variable to manage. But I tried managing it directly with the old Context API, and that convinced me that app-wide state is still a problem worth solving! Here are some things I think legitimately belong in app-wide state:

  • State that needs to be shared between routes. Top-down data propagation runs into a roadblock when you hit a routing boundary. Of course you’ll pass some params to dependent routes, but passing shared state is a recipe for disaster. Just use app state instead.
  • State that is used all over the place. Sure you could put it in your root component and pass it down, but why? As an aside, I think a case can be made to store current session information (e.g., is the user logged in) in state even though you can also do that with Apollo.

In the last app I worked on, we tried Apollo Link State. The logic is compelling: Since you already have a nice library to query and update values in the database, and since most of your data will already be in that format, why not just store your app state the same way? Then you have one way to do things, and you can use the existing reactive query components to update UI elements regardless of whether the data is local or remote.

To be fair, Apollo Link State totally works, though I ran into some sharp edges on what was then a new addition to Apollo. The real problem is that the way you produce and consume local app data just doesn’t match the pattern of remote data. Queries for remote data tend to be specific to one part of your app and don’t repeat much. The queries are intentionally verbose to clarify the relationship between the client and server, and they may reside in their own files and may have options for optimistic updates, updating based on mutations, etc. They also make extensive use of loading/error state, the ability to select which fields you need, etc. But by definition, app data is ubiquitous and simple - usually key-value pairs, or sometimes objects that are needed in various combinations that are read and updated in multiple places in your app. You don’t need to select fields, deal with loading state, etc. Writing queries for these things is simply awkward and excessively verbose, and don’t forget that queries and mutations require separate code.

Here’s an example: We had an app for ordering from restaurants. If you leave your table, it should clear incomplete orders and remove the table number you were sitting at. But you shouldn’t be able to do that if you have an unpaid tab (ticket). We had components with render props to compose in the state queries and mutations, and this was a piece of code that appeared in the app:

  <ClearOrder
    render={clearOrder => (
      <SetCurrentTable
        render={setCurrentTable => (
          <GetOrderStatus
            render={({ ticketId }) => (
              <HeaderButton
                label="Leave Table"
                disabled={!!ticketId}
                onPress={() => {
                  clearOrder();
                  setCurrentTable({ qrCode: '' });
                }}
              />
            )}
          />
        )}
      />
    )}
  />

It’s almost enough to make you go back to higher order components! 😱 Actually, I think HOCs would be a good solution here, but composing individual operations is still pretty heavy.

Enter Unstated

Before we look at Unstated, I should say that you don’t need to use any other state library at all! The new Context API is quite nice and may be all you need, allowing you to keep state in a root component. That said, here’s that same component written with Unstated

<Subscribe to={[Table, Order]}>
  {(currentTable, order) => (
    <HeaderButton
      label="Leave Table"
      disabled={!!order.state.ticketId}
      onPress={() => {
        order.clear();
        currentTable.set({ qrCode: '' });
      }}
    />
  )}
</Subscribe>

I find it much more readable, and there are fewer levels of indentation and fewer lines of code. It avoids the pyramid of doom in two ways:

  • Get and set functionality can be shared in one state object, reducing imports. Order and table state could have been combined to one object if we preferred that. We are dealing with classes, so we can combine as many things as we like on that container.
  • There’s a built-in way to subscribe to multiple state containers in one Subscribe component – the to prop takes an array of state objects.

Finally, if we compare the code to perform clearOrder, one of the actions, the difference becomes a little starker:

The GraphQL is simple enough but doesn’t tell us much:

mutation clearOrder {
  clearOrder @client
}

And the resolver ends up looking a lot like a Redux reducer.

clearOrder: (_, __, { cache }) => {
  const data = cache.readQuery({ query: GET_ORDER });

  // make sure you mutate your object correctly
  // or other state could be affected
  data.items = [];

  cache.writeQuery({ query: GET_ORDER, data });
  return null; // if you forget to return null, you'll get errors
},

The equivalent method in the Unstated class would be a one-liner:

// make sure you bind your method if you might pass it
// as a prop
clear = () => this.setState({ items: [] });

Ah, That feels better!

Testing with unstated

Because Unstated creates an object that is not a react component, you can test it without rendering the component just as you might with Redux:

test('clear order', () => {
  // instantiate and set up our state object for the test
  const orderState = new OrderState();
  orderState.setState({ items: ['hamburger'] });

  orderState.clear();

  expect(orderState.state.items).toEqual({ items: [] });
});

Or you can test it in place by injecting it into a component tree with the inject prop:

test('clear rendered Orders', () => {
  // render an Orders component with the same orderState as above
  // (here with react-test-renderer)
  const tree = TestRenderer.create(
    <Provider inject={[orderState]}>
      <Orders />
    </Provider>,
  );
  // make sure our order rendered (1 item)
  expect(tree.root.findAllByType(OrderItem).length).toBe(1);

  // "press" the leave-table button by calling its onPress, which will
  // call our `clear()` method
  const leaveTableButton = tree.root.findByProps({
    testId: 'leave-table-button',
  });
  button.props.onPress();

  expect(tree.root.findAllByType(OrderItem).length).toBe(0);
});

Follow Your Bliss

In fact, Unstated is so easy to use, it would be easy to use it for everything, but that’s not really the intent of the library. Use it judiciously, though, and it will fill in a small but very important hole in your state management. Using Unstated in my current project feels like I’ve finally arrived at a complete data solution, and I’m no longer wishing for something different.

Let's stay connected. Join our monthly newsletter to receive updates on events, training, and helpful articles from our team.