Hi, my name is Petro and I’m a senior frontend engineer at Secfi. We are helping startup employees understand their equity and assisting some of them to avoid losing their deserved reward.
Secfi is actively growing — client applications are getting bigger and maintaining one global state by hand becomes a difficult task. This article will explore our approach in resolving this.
Summary: What did the migration from MobX to Apollo client give us?
Simplifying our data layer with GQL/Apollo allowed us to reduce a lot of the state management needs and boilerplate — to the point of removing Mobx altogether.
In the end we got:
- smaller amount of abstractions
- most of caching is handled automatically
- code generation (25k-30k lines code removed + backend and frontend always in sync)
- faster loading time due to smaller amount of calls and reduced amount of data transferred
This migration was not done in isolation. There were a lot of data model, tooling and even organizational changes that also occurred in parallel. These all interconnect, work together and influence one another.
How we grew to a point that a refactor was needed
At Secfi we utilize React as the main library for our FE stack so all our other technology choices are based on React and its ecosystem. Before diving into the MobX migration it's important to explore why and how we used MobX in the first place — to provide some much needed context and history behind our decision making process.
When our app grew to the state of needing the state management library (pun intended) we explored the two most common options in the React ecosystem — Redux or MobX. We didn’t like the amount of boilerplate code that we had to write if we went the Redux path and at the same time the MobX community had come up with the MobX-State-Tree library which offered cool benefits, such as runtime type checking, data normalization and clear structure. On top of that we could (and did) set it up in a way that mimicked our backend data model and the microservices structure using the MobX-State-Tree models and actions. The benefit of this was obvious — frontend state structure was in sync with the backend — what can be better? The drawback though was in the actual details behind it.
Problems that started to arise with time
- Models interdependency
- Increasing complexity, plus amount of calls
- Maintainability
To understand how these issues rose from our set-up back then it’s important to show a part of our business data model.
MobX-State-Tree has a great mechanism called actions in their models. These actions allow subscriptions to events on the model and facilitate performing side effects. We used it on all our models to fetch all related models in the tree. When the customer loaded the home page we needed to get all Affiliations for them, resulting in each MobX-State-Tree model of the Affiliation making calls to resolve Company, Company Assessment, Tax Info and arrays of Option Grants and Share Grants. Each of these entities had their own initializing logic to fetch all other entities that they had references to.
Of course there were checks in place to not fetch the same entity (checked by uuid) twice, but this improvement paled in comparison with the amount of REST API calls that were initiated on the page load. For reference — if the customer had indicated that they worked in 5 companies there could be 100 rest api calls initiated on the application load to populate the state with all necessary information. And while we could optimize specifically for the home page by joining all the calls into a new backend endpoint, the overfetching issue would remain on a platform level.
As you might have guessed, this was also not fun to maintain. Models were naturally utilized as a source of parts of the business logic, since they were foundational to the application. Soon enough some of our UI pieces started to be affected as well: we created a separate store for theming configuration; all models grew to have computed properties that were meant for pure UI representation. At some point we realized that the state grew into one very big and hard to maintain creature.
Apollo client to the rescue!
It was clear that the situation had to be improved but where to start? There were different solutions we could leverage to solve this problem, we went with the GraphQL in combination with React Context api — for parts that were client-specific.
Backend transformation
One action point the team decided on was to start utilizing the power of the GraphQL. In our business case the data model is represented in multiple ways by our tools, helping the user to understand their equity options and their complications by presenting them in different ways. Another great benefit was that we could hide the backend implementation and logic altogether and have one orchestration service/facade which would serve as an “API Contract” giving the team certainty in the expected inputs and outputs of each operation. This in turn gave the ability to generate types for the client apps and queries + mutation hooks to write even less code. Last but not least, having data fetched through GraphQL allowed us to retrieve only the necessary bits of the model and not the whole thing. To read a bit more about this — check out the backend article on the migration. Apollo client also gave us local cache out of the box, so here we saved on even more code, complexity and unnecessary api calls.
Frontend transformation
While slowly
migrating most of the API interactions to the facade we realized that our frontend architecture is not well defined and scalable either. We had two client side applications — client facing and admin facing — that were written in quite different ways and at some point it became a real struggle to switch between projects and fix bugs. This motivated us to define one architectural standard for all frontend apps. We'll cover this process and our learnings and wins in a separate article. With every feature refactored we also moved the backend integration to the Apollo client removing the dependency on the central MobX store. One important thing worth mentioning here is — frontend applications have user interactions-driven state and the Apollo client does not cover this part. For smaller pieces of state we utilize React hooks api — useState
, useEffect
and useReducer
. For more complex we use React Context api. There are several top level contexts that handle logic such as authentication, theme and multiple feature-specific contexts throughout the app.
How it works now
First, we define a *.graphql
file in the folder where it is going to be used — eg near the container or specific hook. Example:
fragment AffiliationOverview on Affiliation {
uuid
country
customer {
uuid
}
company {
uuid
name
logo
}
company_assessment {
uuid
}
}
query getAllAffiliationOverview($customerUuid: ID!) {
affiliations: allAffiliations(filters: { customer: $customerUuid }) {
totalCount
nodes {
...AffiliationOverview
}
}
}
The reader can notice that we define uuid
property inside of each entity — we've configured the Apollo client to use uuid as unique identifiers to handle automatic cache updates and linking (by default it uses id
property). Fragment here is a reusable piece of the entity. If we need the same piece of the model in multiple queries in the same file — we move it to the local fragment. If it becomes common for more queries and mutations across the app — we move it to global fragments.
Second, we run the generate
command — it will get all the types from the relevant backend environment.
Now we are able to import the generated hooks and types across our applications and use them as regular React hooks, while ensuring type safety and alignment with our backend.
import { useGetAllAffiliationOverviewQuery } from '@generated';
Queries are quite straightforward. Mutations, on the other hand, become more tricky, especially those that add or remove items in an array. Apollo client is not smart enough to determine how to update the cache in case of addition or removal mutations. There are two ways to do it:
- simple: provide list of queries to refetch, this way the cache gets updated with the fresh response from the backend; drawback — additional backend call(s)
- more complex but more efficient: update cache manually, it saves on the backend calls, but one needs to mutate the cache which might not be trivial in some cases.
Half a year ago we removed the last bits of the MobX in our apps — logic related to authenticating the user, interacting with session tokens and other profile related bits and pieces. Only the data fetching part migrated to the Apollo client implementation, the rest got its own React Provider(s) and now the whole app interacts with those pieces via hooks. That pull request alone reduced our codebase by 5k lines of code. It's not the line count that made the whole team happy that day, but the realization that now we have one way of interacting with the backend and a year+ long migration has been finished.
To reiterate, in the end of this process we got:
- smaller amount of abstractions
- most of caching is handled automatically
- code generation, backend and frontend always in sync
- faster loading time due to smaller amount of calls and reduced amount of data transferred
- and
last but not least
— happier team maintaining all of this!
Top comments (0)