We [OkayCupid] decided against GraphQL for local state management

Why we decided against GraphQL for local state management

Preface and motivation

Here at OkCupid, we’re pretty big fans of using GraphQL. When it comes to fetching data on any of our client platforms, the abstraction that the query language provides grants the flexibility for us to fetch precisely the data that we need in each situation.

At the end of the day, GraphQL really is just that: an abstraction. The mutation, query, and subscription types abstractly model the fundamental ways in which we interact with any data. The schema serves as a contract between some source of data and its destination, and it defines what data can be queried and how it ought to be queried. The data that an incoming query outlines would be resolved by our GraphQL server instance in most cases, but the destination of that data (in our case, let’s say it’s a mobile app or web app acting as a client), doesn’t really need to know about the source or resolution strategy of the data in question.

This is really nice, because it means that the data can come from anywhere that the GraphQL server has access to. Maybe we want to resolve our data using something in the filesystem, or maybe a local database, or maybe a remote one. Perhaps we can call some other server exposed to us, via RPC or REST or any protocol, really. Maybe our data is currently in memory somewhere and that’s technically fine as well! That indifference of our datasource(s) is what allows this model, and the architecture of a data graph to be so scalable (Mandi Wise has a great video that demonstrates this while covering the concept of a federated graph).

Regardless of the source of the data, the client implementation doesn’t really need to change at all, and that’s crucially important to understanding the notion of using GraphQL for local state management. Imagine your application’s local state: it really is just another source of data, after all, isn’t it? So, then the question is, what is stopping us from leveraging the abstraction provided to us by this query language paradigm to manage this data as well?

How it works

Well, the answer is nothing really. If you are storing state somewhere in your application, you can theoretically just resolve GraphQL queries with that state data if you want. The implementation from Apollo (in our opinion, the de facto providers of all things gql) that we experimented with uses a GraphQL directive to denote which pieces of a given query should be resolved in this manner: @client.

For an example of what that would look like, let’s imagine we want to have an application where users can send messages to one another. To get information about who a user has messaged, we’ll probably make a query to our server. However, maybe we want to know whether or not the user currently has a message window open for any of their given conversations. The server doesn’t actually care about this piece of information for each conversation, but our frontend application cares a lot about it, so it makes a lot of sense that we might then choose to store something like this in client state. Assuming that’s the case, one can then construct a reasonable query for that data, local state included, like so:

query getAllMessages($userId: ID!) {
  user(id: $userId) {
    name
    profilePic
    messages {
      id
      correspondent {
        name
        profilePic
      }
      isOpen @client
    }
  }
}

In this example, our isOpen flag for each of user’s messages can be stored in our client’s state, as it isn’t much of a backend concern. The rest of the data, however can be fetched from our server and nothing else needs to change. The ability to mix our datasources (client vs. server) for a single query is a really powerful idea, and can lead to some incredibly flexible single queries.

Due to the strategy for resolving this @client directive being a traversal, it means that this directive can recursively apply across parent-child and neighbor-neighbor relationships in our data, allowing us to achieve the same experience of a data graph, except with our client state. Our client state can have direct access to pieces of its parent (non-client-state) structures, and it can be something as simple as scalar Fields like boolean flags, or even data that is more deeply related and structured.

The client state doesn’t need to be related to some server data, either! It could really be any data we might want to store as client state, like perhaps a user’s current setting for theme preference, an entirely frontend concern but something stateful nonetheless. Dark mode is so in these days, though, right?

So, how does something like this work? Well, under the hood, we need to add some new pieces of configuration to our ApolloClient instance in order for it to have a strategy for resolving client-side queries. To do so, we explicitly spell this out by adding some new resolvers to our client, just like we would write to resolve queries on our server instance. The ApolloClient instance can add resolvers both upon initialization, as well as on an ad-hoc basis using client.addResolver(someNewResolverToAdd). Apollo defines the function signature that handles resolution like so, which ought to look very familiar if you’ve worked with apollo-server in the past:

type ResolverFn = (
  parent: any, 
  args: any, 
  { cache } : { cache: ApolloCache }
) => any;

Ignoring the parent node and the arguments parameters, we see that we destructure a property off of an object, called cache, much like we destructure a dataSources property in the apollo-server parallel to this function type. That’s because in this scenario, our cache has taken the responsibility of the datasource in our client-side world. Let’s see what a client-side resolver setup would look like in this context:

const defaultResolver = {
  Query: {
    user: {
      messages: {
        isOpen: (parent, args, { cache }) => {
          
          // reference the cache to get your data
          
          return cache.readQuery({
            query: MESSAGE_IS_OPEN_QUERY,
            variables: {
              messageId: parent.id,
            }
          });
        },
      },
    },
  },
};

After that, we’ll also want to provide the cache with some initial state too, so that our first cache read will resolve, so we’ll want to define that as well and feed it to our cache upon initialization.

// define initial client-side state 
const defaultState = {
  user: {
    messages: {
      isOpen: false,
    }
  }
}

const client = new ApolloClient({
  // other apollo config, such as link
  // and cache definitions
  resolvers: defaultResolver,
});

// Prime the cache with your initial state
client.writeData({
  query: MESSAGE_IS_OPEN_QUERY,
  data: {
    defaultState,
  }
})

In this example, we’re just setting the default state for a given message

The intuition that Apollo had was to not only provide this directive and the option to resolve queries on the client side, but to offer up the client-side cache that most ApolloClient instances define anyway (which is normally used to store responses for our queries that do actually resolve on the server) as a location to store this client state. This makes a lot of sense, we have a local store (our ApolloClient‘s cache) and we have a way to interact with that store (using gql)… This is, in essence, what a solution like Redux (which I’m sure needs no introduction at this point) or MobX offers us right?

Well, yes. It works perfectly fine, too! As we explored this as an option however, we noticed a few things that eventually led us to make the decision to not rely on Apollo for state management.

The issues we encountered

So why did we decide against implementing this then? Well, the reasoning behind the decision is certainly specific to our scenario and maybe won’t be as important to others, but does contain some insights that I feel will be considerations that anyone going down the Apollo path will have to weigh as well.

The developer overhead and learning curve

Although this works as a stage management solution, it comes with some new overhead. One will have to write resolvers for their client-side state now, and although that is the case for any state management option, it is not as simple as it may seem.

To really write these resolvers properly, you will want to have some experience / aptitude in working with the cache directly. As of version 3.0, apollo/client has had some pretty drastic updates to how the cache handles normalized and non-normalized data. Understanding how the cache uses ids and __typenames, deciding whether to merge or replace data, and learning how to do so are all par for the course with this paradigm. Sidenote, Khalil @ Apollo just recently published an incredible blog post going into depth about the Apollo cache and understanding cache normalization. This gives us one of two options for our cached data: either make sure every query requests a uniquely identifying field of the data we are requesting (doesn’t this defeat the purpose of requesting whatever fields we want?) or writing explicit typePolicies to tell our cache how to normalize our data. From the perspective of someone writing a client-side library that has to work correctly for numerous different use-cases, I understand the motivation for a solution like this. However, this is just not an issue with client-side state via a solution like Redux.

Contrasting this paradigm against something like React’s Context API in tandem with the useReducer hook, or even the Redux architecture, it seems like the Apollo solution is more to understand and manage from a developer perspective. For that tradeoff, though, we do gain the ability to think about and interact with ALL of our application’s data in the same manner, which is undoubtedly an awesome benefit. But is it worth it?

Something new, something borrowed?

Well, we’re already working with Redux here at OkC, and in some examples of older code, even with Reflux. Adding a new option for state management in our app would really cause a jumbled mess that would undeniably be complex for someone who is onboarding onto our team to wrap their head around at first. Personally, I feel developer experience and maintainability ought to be huge defining factors of any architectural or framework decision.

The “Redux is dead” argument has been posed many times, and the traditional costs associated with it that come in the form of tons of boilerplate and wrapping components can easily be argued to not be very scalable, as one has to make changes to a handful of files before getting anywhere. Despite this, though, it definitely has matured over the years. If done right, it can definitely be a breeze to work with and it clearly does have some staying power (not to mention, working with the Redux hooks is actually really nice). Not to mention, ripping out our existing architecture would take months, and adding another paradigm to learn and follow alongside the existing state management paradigms would cause more context switching when devs are writing code, and would probably just confuse and burden them more.

On top of that, there are also countless resources at one’s disposal for using and understanding Redux (or any mature OSS, for that matter), one thing that I certainly had issues with personally when researching the Apollo client-side state management; there just aren’t as many docs, videos, and articles on the Apollo approach, perhaps because it is more obscure or early in its life when compared to something like Redux. Also, a more mature solution could potentially offer more value in the terms of defining a stable API for the developer to work with, whereas a younger one could be more turbulent in that regard (that is all very situational, though, I will admit).

However, the common argument against Redux of having to “change so many files” and “it being overly complicated” doesn’t really seem remedied by Apollo’s solution. We’d still want to sensibly colocate the definition of resolvers and initial state, and after having done something like that myself, I felt like I was just writing the boilerplate for some Redux which felt pretty ironic to me. Working with the cache directly doesn’t seem less complicated to me than working with the store, either.

Apollo also has a Dev Tools offering as well, which I really liked using and found to be useful as well, but it too feels a tad immature when put up against something like Redux’s parallel. Sometimes, it doesn’t want to launch. It doesn’t offer interesting features like Time Travel, and the one thing I was excited about using it for, which was the client-side introspection, would have required defining typeDefs on the ApolloClient instance (this is essentially the process of creating a schema for our client-side state, which is really just another whole set of things to worry about and manage, but I’ll admit that perhaps Typescript or codegen could really shine in this scenario). In other libraries I’ve used, there isn’t a need to define the shape of our client-side state multiple times, and if anything, it’s starting to seem like some Apollo-side boilerplate that is starting to accumulate.

Other options

We also have been experimenting with React’s Context API, and presumably will want to consider some other options in the future as well. However, the consideration of what we choose having implications on our bundle size is super important to us as well. Could Context/Apollo remove the need for a state management dependency altogether? For some simpler applications, I think there examples out there where Context has been proven to be more than enough. Likewise, there are some examples of Apollo’s solution being more than enough as well! There’s also MobX, Facebook’s new open source offering Recoil, and even modeling your client-side state using a state machine with XState.

Where do we net out?

It is hard not to recognize the incredible work that Apollo has done. Apollo and GraphQL have done wonders for cleaning up our APIs and the general networking layer of our clients. Pursuing it as an option for state management, however, has major implications for our codebase, and at this time we just didn’t feel the argument was compelling enough given the maturity of Apollo’s client-side state management and the current setup of our client-side state. The return-on-investment of implementing something new in a codebase with a lot of existing architecture needs to be relatively high in my opinion. I’m just not sure that the return on investment with Apollo is high enough in this case.

Armed with some new insights after having explored Apollo’s offering some more, though, we will hopefully be able to come to a better conclusion eventually about what we should rely on to solve the problem, and how we should think about the approach, architecture, and trade-offs of any library, framework, or tool we decide to roll with. Until then, we’ll continue to keep on our eyes on how Apollo’s client state solution evolves.

 

Read More

Leave a Comment