Migrating from Vuex
Why?
We have defined the GraphQL API as the primary API for all user-facing features, so we can safely assume that whenever GraphQL is present, so will the Apollo Client. We do not want to use Vuex with Apollo, so the VueX stores count will naturally decline over time as we move from the REST API to GraphQL.
This section gives guidelines and methods to translate an existing VueX store to pure Vue and Apollo, or how to rely less on VueX.
How?
Overview
As a whole, we want to understand how complex our change will be. Sometimes, we only have a few properties that are truly worth being stored in a global state and sometimes they can safely all be extracted to pure Vue
. VueX
properties generally fall into one of these categories:
- Static properties
- Reactive mutable properties
- Getters
- API data
Therefore, the first step is to read the current VueX state and determine the category of each property.
At a high level, we could map each category with an equivalent non-VueX code pattern:
- Static properties: Provide/Inject from Vue API.
- Reactive mutable properties: Vue events and props, Apollo Client.
- Getters: Utils functions, Apollo
update
hook, computed properties. - API data: Apollo Client.
Let's go through an example. In each section we refer to this state and slowly go through migrating it fully:
// state.js AKA our store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
blobPath,
summaryEndpoint,
suiteEndpoint,
testReports: {},
selectedSuiteIndex: null,
isLoading: false,
errorMessage: null,
limit : 10,
pageInfo: {
page: 1,
perPage: 20,
},
});
How to migrate static values
The easiest type of values to migrate are static values, either:
- Client-side constants: If the static value is a client-side constant, it may have been implemented
in the store for easy access by other state properties or methods. However, it is generally
a better practice to add such values to a
constants.js
file and import it when needed. - Rails-injected dataset: These are values that we may need to provide to our Vue apps.
They are static, so adding them to the VueX store is not necessary and it could instead
be done easily through the
provide/inject
Vue API, which would be equivalent but without the VueX overhead. This should only be injected inside the top-most JS file that mounts our component.
If we take a look at our example above, we can already see that two properties contain Endpoint
in their name, which probably means that these come from our Rails dataset. To confirm this, we would search the codebase for these properties and see where they are defined, which is the case in our example. Additionally, blobPath
is also a static property, and a little less obvious here is that pageInfo
is actually a constant! It is never modified and is only used as a default value that we use inside our getter:
// state.js AKA our store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
limit
blobPath, // Static - Dataset
summaryEndpoint, // Static - Dataset
suiteEndpoint, // Static - Dataset
testReports: {},
selectedSuiteIndex: null,
isLoading: false,
errorMessage: null,
pageInfo: { // Static - Constant
page: 1, // Static - Constant
perPage: 20, // Static - Constant
},
});
How to migrate reactive mutable values
These values are especially useful when used by a lot of different components, so we can first evaluate how many reads and writes each property gets, and how far apart these are from each other. The fewer reads there are and the closer together they live, the easier it will be to remove these properties in favor of native Vue props and events.
Simple read/write values
If we go back to our example, selectedSuiteIndex
is only used by one component and also once inside a getter. Additionally, this getter is only used once itself! It would be quite easy to translate this logic to Vue because this could become a data
property on the component instance. For the getter, we can use a computed property instead, or a method on the component that returns the right item because we will have access to the index there as well. This is a perfect example of how the VueX store here complicates the application by adding a lot of abstractions when really everything could live inside the same component.
Luckily, in our example all properties could live inside the same component. However, there are cases where it will not be possible. When this happens, we can use Vue events and props to communicate between sibling components. Store the data in question inside a parent component that should know about the state, and when a child component wants to write to the component, it can $emit
an event with the new value and let the parent update. Then, by cascading props down to all of its children, all instances of the sibling components will share the same data.
Sometimes, it can feel that events and props are cumbersome, especially in very deep component trees. However, it is quite important to be aware that this is mostly an inconvenience issue and not an architectural flaw or problem to fix. Passing down props, even deeply nested, is a very acceptable pattern for cross-components communication.
Shared read/write values
Let's assume that we have a property in the store that is used by multiple components for read and writes that are either so numerous or far apart that Vue props and events seem like a bad solution. Instead, we use Apollo client-side resolvers. This section requires knowledge of Apollo Client, so feel free to check the apollo details as needed.
First we need to set up our Vue app to use VueApollo
. Then when creating our store, we pass the resolvers
and typedefs
(defined later) to the Apollo Client:
import { resolvers } from "./graphql/settings.js"
import typeDefs from './graphql/typedefs.graphql';
...
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient({
resolvers, // To be written soon
{ typeDefs }, // We are going to create this in a sec
}),
});
For our example, let's call our field app.status
, and we need is to define queries and mutations that use the @client
directives. Let's create them right now:
// get_app_status.query.graphql
query getAppStatus {
app @client {
status
}
}
// update_app_status.mutation.graphql
mutation updateAppStatus($appStatus: String) {
updateAppStatus(appStatus: $appStatus) @client
}
For fields that do not exist in our schema, we need to set up typeDefs
. For example:
// typedefs.graphql
type TestReportApp {
status: String!
}
extend type Query {
app: TestReportApp
}
Now we can write our resolvers so that we can update the field with our mutation:
// settings.js
export const resolvers = {
Mutation: {
// appStatus is the argument to our mutation
updateAppStatus: (_, { appStatus }, { cache }) => {
cache.writeQuery({
query: getAppStatus,
data: {
app: {
__typename: 'TestReportApp',
status: appStatus,
},
},
});
},
}
}
For querying, this works without any additional instructions because it behaves like any Object
, because querying for app { status }
is equivalent to app.status
. However, we need to write either a "default" writeQuery
(to define the very first value our field will have) or we can set up the typePolicies
for our cacheConfig
to provide this default value.
So now when we want to read from this value, we can use our local query. When we need to update it, we can call the mutation and pass the new value as an argument.
Network-related values
There are values like isLoading
and errorMessage
which are tied to the network request state. These are read/write properties, but will easily be replaced later with Apollo Client's own capabilities without us doing any extra work:
// state.js AKA our store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
blobPath, // Static - Dataset
summaryEndpoint, // Static - Dataset
suiteEndpoint, // Static - Dataset
testReports: {},
selectedSuiteIndex: null, // Mutable -> data property
isLoading: false, // Mutable -> tied to network
errorMessage: null, // Mutable -> tied to network
pageInfo: { // Static - Constant
page: 1, // Static - Constant
perPage: 20, // Static - Constant
},
});
How to migrate getters
Getters have to be reviewed case-by-case, but a general guideline is that it is highly possible to write a pure JavaScript util function that takes as an argument the state values we used to use inside the getter, and then return whatever value we want. Consider the following getter:
// getters.js
export const getSelectedSuite = (state) =>
state.testReports?.test_suites?.[state.selectedSuiteIndex] || {};
All that we do here is reference two state values, which can both become arguments to a function:
//new_utils.js
export const getSelectedSuite = (testReports, selectedSuiteIndex) =>
testReports?.test_suites?.[selectedSuiteIndex] || {};
This new util can then be imported and used as it previously was, but directly inside the component. Also, most of the specs for the getters can be ported to the utils quite easily because the logic is preserved.
How to migrate API data
Our last property is called testReports
and it is fetched via an axios
call to the API. We assume that we are in a pure REST application and that GraphQL data is not yet available:
// actions.js
export const fetchSummary = ({ state, commit, dispatch }) => {
dispatch('toggleLoading');
return axios
.get(state.summaryEndpoint)
.then(({ data }) => {
commit(types.SET_SUMMARY, data);
})
.catch(() => {
createAlert({
message: s__('TestReports|There was an error fetching the summary.'),
});
})
.finally(() => {
dispatch('toggleLoading');
});
};
We have two options here. If this action is only used once, there is nothing preventing us from just moving all of this code from the actions.js
file to the component that does the fetching. Then, it would be easy to remove all the state related code in favor of data
properties. In that case, isLoading
and errorMessages
would both live along with it because it's only used once.
If we are reusing this function multiple time (or plan to), then that Apollo Client can be leveraged to do what it does best: network calls and caching. In this section, we assume Apollo Client knowledge and that you know how to set it up, but feel free to read through the GraphQL documentation.
We can use a local GraphQL query (with an @client
directive) to structure how we want to receive the data, and then use a client-side resolver to tell Apollo Client how to resolve that query. We can take a look at our REST call in the browser network tab and determine which structure suits the use case. In our example, we could write our query like:
query getTestReportSummary($fullPath: ID!, $iid: ID!, endpoint: String!) {
project(fullPath: $fullPath){
id,
pipeline(iid: $iid){
id,
testReportSummary(endpoint: $endpoint) @client {
testSuites{
nodes{
name
totalTime,
# There are more fields here, but they aren't needed for our example
}
}
}
}
}
}
The structure here is arbitrary in the sense that we could write this however we want. It might be tempting to skip the project.pipeline.testReportSummary
because this is not how the REST call is structured. However, by making the query structure compliant with the GraphQL
API, we will not need to modify our query if we do decide to transition to GraphQL
later, and can simply remove the @client
directive. This also gives us caching for free because if we try to fetch the summary again for the same pipeline, Apollo Client knows that we already have the result!
Additionally, we are passing an endpoint
argument to our field testReportSummary
. This would not be necessary in pure GraphQL
, but our resolver is going to need that information to make the REST
call later.
Now we need to write a client-side resolver. When we mark a field with an @client
directive, it is not sent to the server, and Apollo Client instead expects us to define our own code to resolve the value. We can write a client-side resolver for testReportSummary
inside the cacheConfig
object that we pass to Apollo Client. We want this resolver to make the Axios call and return whatever data structure we want. That this is also the perfect place to transfer a getter if it was always used when accessing the API data or massaging the data structure:
// graphql_config.js
export const resolvers = {
Query: {
testReportSummary(_, { summaryEndpoint }): {
return axios.get(summaryEndpoint).then(({ data }) => {
return data // we could format/massage our data here instead of using a getter
}
}
}
Any time we make a call to the testReportSummary @client
field, this resolver is executed and returns the result of the operation, which is essentially doing the same job as the VueX
action did.
If we assume that our GraphQL call is stored inside a data property called testReportSummary
, we can replace isLoading
with this.$apollo.queries.testReportSummary.lodaing
in any component that fires this query. Errors can be handled inside the error
hook of the Query.
Migration strategy
Now that we have gone through each type of data, let's review how to plan for the transition between a VueX-based store and one without. We are trying to avoid VueX and Apollo coexisting, so the less time where both stores are available in the same context the better. To minimize this overlap, we should start our migration by removing from the store all that does not involve adding an Apollo store. Each of the following point could be its own MR:
- Migrate away from Static values, both
Rails
dataset and client-side constants and useprovide/inject
andconstants.js
files instead. - Replace simple read/write operations with either:
-
data
properties andmethods
if in a single component. -
props
andemits
if shared across a localized group of components.
-
- Replace shared read/write operations with Apollo Client
@client
directives. - Replace network data with Apollo Client, either with actual GraphQL calls when available or by using client-side resolvers to make REST calls.
If it is impossible to quickly replace shared read/write operations or network data (for example in one or two milestones), consider making a different Vue component behind a feature flag that is exclusively functional with Apollo Client, and rename the current component that uses VueX with a legacy-
prefix. The newer component might not be able to implement all functionality right away, but we can progressively add them as we make MRs. This way, our legacy component is exclusively using VueX as a store and the new one is only Apollo. After the new component has re-implemented all the logic, we can turn the Feature Flag on and ensure that it behaves as expected.
FAQ
What if I need a global store without any network call?
This is a rare occurrence and should suggest the following question: "Do I really need a global store then?" (the answer is probably no!) If the answer is yes, then you can use the shared read/write technique with Apollo described above. It is perfectly acceptable to use Apollo Client for client-side exclusive stores.
Are we going to use Pinia?
The short answer is: we don't know, but it is unlikely. It would still mean having two global store libraries, which has the same downsides as VueX and Apollo Client coexisting. Reducing the size of our global stores is positive regardless of whether we end up using Pinia though!
Apollo client is really verbose for client directives. Can I mix and match with VueX?
Mixing and matching is not recommended. There are a lot of reasons why, but think of how codebases grow organically with what is available. Even if you were really good at separating your network state and your client-side state, other developers might not share the same dedication or simply not understand how to choose what lives in which store. Over time, you will also nearly inevitably need to communicate between your VueX store and Apollo Client, which can only result in problems.