Two Years with MST-GQL in Production

Written by lemarko | Published 2023/09/14
Tech Story Tags: web-development | graphql | state-management | mst-gql | mobx-state-tree | mst-gql-in-production | mst-gql-community | graphql-state-management

TLDRThis article explores the transformative journey of a development team that switched from Redux to MST-GQL while adopting GraphQL for state management. The author details the benefits, challenges, and real-world experiences with MST-GQL, providing insights into its strengths and areas for improvement. Despite some hurdles, MST-GQL has significantly improved their development workflow and deserves more recognition in the tech community.via the TL;DR App

It's been two years since my team and I started using MST-GQL, a powerful tool that has become integral to our workflow. As we navigated the complexities and opportunities it offered, we found ourselves becoming more efficient, more agile, and, quite simply, better at what we do — although not without a cost.

Now, I feel compelled to share our experience with you. In this article, I'll dive deep into the specifics of how MST-GQL has transformed our day-to-day operations and why I believe this underrated project deserves more attention and recognition in the tech community.

Content Overview

  • A long journey for a batter store and API layer
  • Moving to GraphQL
  • MST-GQL
  • A quick overview of how MST-GQL works
  • MST-GQL Community
  • What’s next?
  • FInal thoughts

A long journey for a better store and API layer

Several years ago we were using redux and redux-saga for maintaining our app's global state, handling HTTP requests routines, and storing responses in the Redux store.

It was a powerful combination that allowed us to create scenarios for requesting, transforming and storing data from our backend with data related to user interactions.

Redux-saga is a sophisticated tool that includes advanced features like task cancellation, easy error handling, complex control flow management, and more. It's great when you need a deterministic way to handle side effects in your application, such as dealing with complex asynchronous requests, and when you want to decouple your application logic from the components themselves.

However, it wasn't convenient as it required a huge boilerplate for ANY, even a simple request, and all the data from the backend went to the Redux store. Taking into account the data transformers we got inconsistent and sometimes duplicated data structures in the app global state.

Not to mention how difficult it was for new joiners to comprehend redux-saga code.

Sagas are essentially generator functions. Generators produce a sequence of values on an as-needed basis, yielding a series of values over time, rather than computing them at once and holding them in memory. While this can be advantageous for handling asynchronous actions in your application, it also adds complexity to your tests. I heard that for some, the generator’s nature of sagas is an advantage in terms of testing but I, personally hated it every time I needed a new test or to edit an existing one. For me and other devs, it was inconvenient and unintuitive.

The learning curve is quite steep because of the complex concepts involved in this stack, I mean first of all generator functions. Also, debugging was difficult, especially when I had complex control flows. Another issue is the verbosity of code. In Redux, even a simple change requires modifications in several places which can slow down the development speed.

As often happens in redux-involved projects (and sagas too), we created helpers to reduce the amount of code required for repetitive operations, simplifying basic requests, etc. I think if it happened, then the tools we used weren't the perfect fit for us. We needed something better, the tools that would improve productivity and simplicity of maintenance.

Moving to GraphQL

The change was quite radical, the BE moved to GraphQL and we had to transform our FE app to utilize these new opportunities. It was important to choose the best GQL client for our case.

As for GraphQL clients in 2020, there were several prominent options such as Apollo Client, Relay, and URQL. Each of these has its own strengths and weaknesses (what doesn't!).

Apollo Client is a comprehensive GraphQL client, well-known for its large community and powerful feature set. It supports all of GraphQL's features, includes intelligent caching, and integrates well with popular libraries and tools, like React. However, it comes with a complex API and a large bundle size. Additionally, Apollo Client's caching can be a double-edged sword, as it's powerful but sometimes overcomplicated.

Relay Modern, designed by Meta, prioritizes performance and tight integration with React. It offers features like automatic data fetching, optimistic updates, and garbage collection. However, the initial setup and learning curve are steep, and the documentation and community support are not as extensive as Apollo Client’s.

MST-GQL

The final choice was quite unconventional. Our journey led us to a lesser-known library, MST-GQL, which combines the strengths of MobX State Tree (MST) and GraphQL.

Yep, we decided to replace Redux with MobX State Tree. What we appreciated the most was its provision of a very smart way to merge server and user data without modifying the GQL cache. Moreover, we were on the path of migrating to TypeScript, so it suited us even better.

Our decision to replace Redux with MST wasn't taken lightly. Redux had been a central part of our project, and transitioning away from it represented a significant change in our approach to state management. However, the benefits of MST eventually swayed us.

MST makes managing and manipulating data straightforward and efficient. Combining this with the powerful querying capabilities of GraphQL makes MST-GQL a compelling option.

In Redux, the state is immutable and actions lead to new states through reducers. This means that even a small change in the state leads to the creation of an entirely new state object. In a large application like ours, this could lead to significant performance issues. On the other hand, MST treats the state as a mutable object graph. This allows for efficient changes without the overhead of creating new state objects for every single action.

MST's concept of actions being methods that modify the state, as opposed to Redux's plain object actions dispatched to reducers, provided us with a more straightforward and intuitive approach. You might wonder, "Did you just say modifying state?" True, I did. But MST doesn’t simply modify a JavaScript object in an unpredictable manner, it uses the Observable pattern and track all actions.

This led to cleaner, more readable code, and eliminated a lot of the boilerplate that was required with Redux.

We also felt that MST's approach to managing complex states was simpler. MST uses a tree structure to organize the state, allowing for clear, hierarchical arrangements of state variables. This, along with MST's powerful model definition capabilities, allowed us to encapsulate related pieces of state and their associated actions into self-contained models.

With MST-GQL, we managed to overcome many of the challenges we previously faced. The data in our global state was no longer duplicated or inconsistent due to its smart caching mechanism. Furthermore, it allowed us to leverage the benefits of GraphQL to the fullest, including efficient data fetching, type safety, and self-documenting APIs.

Speaking of TypeScript, it was great that MST-GQL natively supported TypeScript, which significantly improved our development experience. With MST-GQL, TypeScript can leverage the type information provided in our GraphQL schema. MST-GQL generates TypeScript types and models from our GraphQL schema.

MST-GQL's tight integration with TypeScript allowed us to leverage the benefits of both, creating a synergy that extended beyond the sum of their individual parts. MST's feature of model instances as TypeScript types was a particular boon, allowing us to write less code and achieve the same functionality, while also providing the type safety.

Its model approach is also handy while testing, allowing us to create entities with model constructors, providing types and code auto-completion.

A quick overview

So, let’s have a look at how it works. Let’s say we have this simple schema:

type Customer {
  id: ID
  firstName: String!
  lastName: String!
  email: String!
  isEmailConfirmed: Boolean!
}
type Order {
  id: ID
  customer: Customer!
  task: String!
}
type Query {
  customers: [Customer]
  orders: [Order]
}
type Mutation {
  confirmEmail(id: ID!): Customer
  updateFirstName(id: ID!, firstName: String!): Customer
  updateLastName(id: ID!, lastName: String!): Customer
}

All you need is to run mst-gql code generator which will create a set of files, such as models and types.

export const CustomerModelBase = ModelBase.named("Customer")
  .props({
	  __typename: types.optional(types.literal("Customer"), "Customer"),
	  id: types.identifier,
	  firstName: types.union(types.undefined, types.string),
	  lastName: types.union(types.undefined, types.string),
	  isEmailConfirmed: types.union(types.undefined, types.boolean),
  })
  .actions(self => ({
		confirmEmail(): Query<{
	    confirmEmail: CustomerModelType
	  }> {
	    return self.store.mutateConfirmEmail({ id: self.id }, undefined, () => {
	      self.isEmailConfirmed = true
	    })
	  },

The CustomerModelType and OrderModelType will also be generated accordingly.

export const OrderModelBase = ModelBase.named("Order").props({
  __typename: types.optional(types.literal("Order"), "Order"),
  id: types.identifier,
  customer: types.union(types.undefined, MSTGQLRef(types.late(() => Customer))),
  task: types.union(types.undefined, types.string)
})

The models above are considered “base” models, which are generated from the schema types and relations. They're not intended to be edited by us. So, when we need to extend these with calculated fields or actions, we add these to a custom twin model. The template for this twin model is also auto-generated.

import { CustomerModelBase } from "./CustomerModel.base"

export const CustomerModel = CustomerModelBase
    // This is an auto-generated example action.
    log() {
      console.log(JSON.stringify(self))
    }
}))

Let's assume that we frequently need the full name of a customer. Instead of calculating it within the render layer each time, we will add a view for it to the model. For that purpose, I'll add a view getter that will be a member of the model.

import { CustomerModelBase } from "./CustomerModel.base"

export const CustomerModel = CustomerModelBase
  .actions(self => ({
    // This is an auto-generated example action.
    log() {
      console.log(JSON.stringify(self))
    }
  }))
  .views(self => ({
    get fullName() {
      return [self.firstName, self.lastName].filter(Boolean).join(' ')
    }
  }))

Yay, our custom view is now accessible from the component 🎉

import { observer } from "mst-gql"
import { useEffect } from "react"
import { CustomerModelType } from 'store'

export const Customers = observer(() => {
  const store = useStore()

  useEffect(function fetchCustomers() {
    // RootStoreBase auto-generated query
    store.queryCustomers()
  }, [])

  return (
    <div>
      <div>Customers: </div>
      <ul>
        {/* I am explicitly specifying the type for illustrative purposes only. 
				It's not necessary for you to use it as it's already typed. */}
        {store.customers?.map((customer: CustomerModelType) => (
          <li>{customer.**fullName**}</li>
        ))}
      </ul>
    </div>
  )
})

All mutations and queries are strictly typed, ensuring that the parameters undergo type checking and the query methods return the correct types. However, it is common to create wrapper methods for the generated actions to specify which fragments of the result set should be fetched.

You may notice that I'm wrapping my component with the observer() Higher Order Component. This tool allows our components to be aware of changes in specific parts of the store to which they are subscribed. So, any part of your application that needs to respond to state changes can be designated as an 'observer'.

MST-GQL uses observables to automatically keep your local state in sync with your remote GraphQL API. Whenever you make a query or mutation, MST-GQL automatically updates the relevant parts of your state and triggers a reaction in any observers.

Since MST-GQL only triggers reactions in components that observe the specific parts of the state that changed, your React components will only re-render when necessary. This leads to efficient, optimized rendering.

I won't cover every aspect of this beautiful library in this article; let's reserve that for the documentation. However, I hope you now have a general understanding of the fundamental concepts behind it.

MST-GQL community

Despite its numerous advantages, MST-GQL has some problems and I would point out one of the most important ones - its popularity is quite low which leads to a set of complications.

The documentation for MST-GQL, as it currently stands, is somewhat lacking in comprehensiveness. While it provides the basics, more complex use cases, and advanced topics are not covered extensively. To make up for the shortfall, developers often need to refer to the MobX State Tree (MST) documentation, as MST-GQL builds on MST's core concepts.

When I refer to its popularity, I'm talking about this library being more like an art-house film or a hipster coconut latte. It has less than two thousand downloads per week and less than 1k GitHub stars (c’mon let them gain some more!).

While these numbers are gradually growing, they are not yet at par with those of more established libraries like Apollo Client, or even MobX and MST on their own.

It leads to another challenge when working with MST-GQL. I found it difficult to search for solutions to specific errors. The number of questions and answers available on Stack Overflow or GitHub issues is limited. Consequently, you may find yourself spending more time than is ideal on troubleshooting and resolving issues. Don't say I didn't warn you. Googling for MST issues can help though.

Looking at the repo, development doesn't seem to be very active, as the last update was about a year ago from the time I'm writing this. The link to the Spectrum chat returns a 404 error. However, there is an open discussion dating back to May 2023 about the next major release.

Hopefully, it will attract more developers and users in the future. In my opinion, it deserves so.

What’s next?

After two years of using MST-GQL in production, we've come to realize that while it solved certain challenges, it has also introduced new ones. Here's a detailed look at the hurdles we've encountered:

Observer HOC ambiguity

A recurring challenge we face is the inconsistent necessity of wrapping components with observer() HOC. As a rule of thumb, I ended up wrapping a component into observer() if it uses useStore(). However, this hasn't been a foolproof guideline; sometimes the component works just fine without the observer() inheriting binding from its parent.

Issues with useMemo()

The MST doesn't play nicely with React's useMemo() hook, especially when dealing with arrays. MST maintains the same array reference even after updates, which useMemo() fails to detect, leading to unexpected behaviour and potential performance pitfalls.

Code generator shortcomings

The auto-generated code from MST-GQL has its limitations, including generating invalid or incorrect code, such as wrong import orders in the store index file. It's an area in dire need of improvement.

Unused code

MST-GQL tends to generate an excessive amount of code—around 95% of which we don’t use. We generally prefer using the generated types and handling the data within custom hooks. I believe there are much better tools for generating types out there without creating too many files.

Type generator

We've found that specialised tools for type generation are generally better suited for our needs than an all-in-one solution like MST-GQL. These tools are often more reliable and allow for greater flexibility and customisation.

Store updates

MST-GQL is smart, and once you get used to it, it becomes even more painful when it doesn't work as expected. I ran into this issue when attempting to update the store after certain mutations, specifically when removing an entity. Ideally, the data in the store would automatically update to reflect this change. Instead, the store's data remained stubbornly unchanged, but only in specific scenarios. To get around this, I had to develop a workaround, adding an unexpected layer of complexity to what should have been a simple process.

Mixing GQL cache and app state

There’s a reason why we avoid creating custom models. Over time, we came up with that it’s not the best idea to mix the GQL cache and the app state. We found it beneficial to make queries/mutations and store the user data separately, as we don’t have much global user data to store at some sophisticated storage. Instead, we can store that in the React Context and it would be just enough. On the other hand, server data is needed to be always up-to-date and there’s no good reason to keep it globally to reuse by other component trees or pages. Mostly, we only need to share what we fetched within the current page, and it can be achieved with React context.

To make it convenient to use I created a gist (the link is below) with PageContextFactory It’s a helper designed to reduce boilerplate when creating React contexts. It allows us to easily create contexts for pages (or other component trees), enabling the sharing of the state and logic across the component tree.

Final thoughts

Alright, it doesn’t look like the best advertisement for MST-GQL. But honestly, I have a deep appreciation for this library and the thoughtful engineering behind it. It is great in many ways, opening new avenues for efficient and intuitive state management.

It's true, that no tool is a universal fit for every project, but MST-GQL comes pretty close in offering a balanced, feature-rich package that has made our coding lives significantly better in so many aspects.

To the MST-GQL team, kudos on what you've built so far, and I genuinely hope the project continues to grow and attract more contributors.


PageContextFactory gist:

https://gist.github.com/LeMark0/28c7c8d0edcafb76ea77f4a0948d88fc?embedable=true


Lead image by Egor Myznik on Unsplash


Written by lemarko | Web developer, photographer, composer, daddy cool
Published by HackerNoon on 2023/09/14