What we've learned and what we can do better.
Working in GraphQL presents some unique challenges for us, and we've come a long way in our understanding of it. When Bidx New Roads first got started we didn't have that experience, and with all the upcoming work it's a good time to take a step back and reevaluate our practices. What can we do to make our code understandable, maintainable, and expandable?
Meaningful, straightforward changes
I spent some time researching GraphQL: how it's used by frontend teams, best practices, and tooling. All of these things informed my approach to rewriting some of our data handling code, and I'm now prepared to put forward some suggestions on how we can do better. These changes are easy to implement gradually and will go a long way to making our code cleaner.
Part One: Abolish logic in store mutations
This one is critical! At the moment there's a lot of hidden complexity in our modules caused by fragmented logic. When we fetch data from the API and place it in the vuex store, we sometimes manipulate that data inside of actions and mutations. The result is that it's often unclear if or when data was changed between the API response and the place it's being consumed.
// lettings/store/mutations.js
SET_RECOMMENDED_PROPOSALS(state, proposals) {
state.recommendedProposals = proposals.map((prop) => {
return {
favoritesCount: prop.itemCount + prop.countyCount,
...prop.proposal,
}
})
},
SET_PROPOSAL_SUMMARY(state, proposal) {
state.proposalSummaries = {
...state.proposalSummaries,
[proposal.proposals[0].agencyid]: {
...state.proposalSummaries[proposal.proposals[0].agencyid],
[proposal.proposals[0].contid]: proposal.proposals[0],
},
}
},
SET_SHOW_PLANHOLDER_ENTRY_SETTINGS(state, agencies) {
state.showEligibleBidderListSettings = agencies.reduce(
(settings, agency) => ({ ...settings, [agency.agencyid]: agency.agencySettings?.showeligiblebidderlist }),
{}
)
},
...
This hidden complexity makes tracing the flow of data complicated; there's no indication that data was changed when viewing it in the component. If data is sent in a format that doesn't suit our components, there's a few things we can do:
Change the query
GraphQL does offer some features in its query language that allow us to transform data to an extent, and this can often solve small problems. Features like aliases and directives should be taken advantage of where possible
Modify data in views
If we need to massage data into its intended format, that logic should live in Vue components rather than hidden in random mutations. If multiple views all need to perform the same logic on some data then that logic should live in a composable, which can act like a sort of middle layer between model and view. This does the same thing as our current store mutation logic, but makes it explicit when it's invoked from the component that uses it.
This and the previous point lead to logic being pushed to either the beginning or the end of the data flow, which makes it clear where complexity lies. When we look at a query in the network tab of our browser, we can trust that the data in the store will look the same.
Change the API
If we need to manipulate data in the exact same way every time and everywhere that we use it, it's time to have a conversation about changing the schema to suit our needs. GraphQL advertises itself as a versionless API where new fields can continually be added as the program evolves without breaking older use cases. If that's the case then we should be taking advantage of that paradigm and requesting data in formats that better suit our needs.
Part Two: Moving from model-centric queries to view-centric queries
We currently structure our queries around a single schema object which feels familiar coming from a REST API, but GQL recommends a different approach. Instead of basing our queries on a certain model, we should instead create a single query per view in the app. These queries can fetch data from multiple models at once and the one to one relationship between views and queries is clearer. What's more, since multiple pages don't share a query we aren't fetching data that isn't needed for the view, which is the main selling point of GQL!
To this end the store in each module might store each view's data in a top level field:
// workspace/store/state.js
// old style
export default {
items: null,
counties: null,
recommendedProposals: null,
proposalSummaries: {},
showEligibleBidderListSettings: {},
notificationPreference: {}
}
// new style
export default {
dashboardViewData: null,
favoritesViewData: null,
favoriteCountiesOverlayData: null,
favoriteItemsOverlayData: null,
}
Sometimes data has to be refetched, and this data can be broken out into separate queries so we don't fetch more data than we need. Fragments ensure that there's no needless repetition and make explicit what data will be fetched separately.
fragment ProposalCards on Favorite {
__typename
... on Proposal {
contid
agencyid
callorder
etc...
}
}
query dashboardView ($proposalFilter: [FavoriteQueryInput!]) {
agencies {
agencyid
agencySettings {
showeligiblebidderlist
}
}
followedProposals: favorites(filters: $proposalFilter) {
...ProposalCards
}
etc...
}
query refetchDashboardProposals ($proposalFilter: [FavoriteQueryInput!]) {
followedProposals: favorites(filters: $proposalFilter) {
...ProposalCards
}
}
Notice here that we're aliasing that part of the query as followedProposals so that the data is named appropriately for its purpose.
Bigger changes we can explore
The above is easy to implement gradually over time and without requiring any major changes or help from other teams. They make our current process better, but they don't change it a whole lot. We should start with this (and reap the benefits) and then we can consider what might improve things further (and whether they're worth our time). Here's a couple suggestions I want to explore more.
Returning changed state in mutations
Surprisingly a few mutations in Bidx already do this, and that ought to be more consistent. This has the benefit of eliminating the need for refetching data, but requires a little more management of local state. Whether this would be better is debatable, but it could help with cases where an action completes but the data on screen doesn't update immediately.
The real reason I suggest it however is because it would help with the second and more significant change:
Adopting a frontend GQL library
There are a number of tools for using graphql in Vue, including Apollo, Villus, and urql. Each one takes a slightly different approach, but they all allow us to integrate queries directly into the components that need them, and implement some degree of automatic caching that reduces redundant fetches.
vue-apollo in particular is soon releasing version 4.0 which brings a composition API approach to queries that shows a lot of promise. A component might implement it like this:
<script setup>
import { useQuery } from '@vue/apollo-composable'
import gql from 'graphql-tag'
const { result, loading, error } = useQuery(gql`
query getUsers {
users {
id
firstname
lastname
email
}
}
`)
</script>
This immediately gives us local state management, as well as tracking of loading and error states. It also allows us to easily divide queries into the individual components that need them and display more granular loading states. It takes a lot of the burden off of us in terms of managing data, but it fundamentally changes how we approach data in Bidx. Would those benefits outweigh the additional burden? I don't know. I think there's merit to keeping our processes relatively similar between the REST and GQL apps, but that's something I hope we can discuss more in the future.
Not everything will be cut and dry and I'm sure there will be many exceptions to these rules, but approaching all our future work with some common sense strategies will go a long way to making our work easier. I'm excited to discuss and evolve these suggestions as we put them into practice.
← Back to blog