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.
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.
I agree that this makes testing very easy, but I have two problems with it:
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.
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:
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.
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:
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 propclear = () => this.setState({ items: [] });
Ah, That feels better!
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);});
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.
Originally published at www.okgrow.com.