I find the API to expose Apollo state to UI components too complex, especially considering that this is an operation we’ll have to do for almost every single component. So I’ll start with an example and share some iterative improvement ideas; here it is:
const TopicHeader = connect({
mapQueriesToProps({ ownProps }) {
return {
topic: {
query: gql`
query getTopic ($topicId: ID) {
oneTopic(id: $topicId) {
id
category_id
title
}
}
`,
variables: {
topicId: ownProps.topicId,
}
}
}
},
})(RawTopicHeader);
It’s clear from this example that a lot of code is duplicated, but there is actually a good reason for that which is that some symbols live in the JS world and some in the GraphQL world (eg { query: 'query ...' }
). And that’s a hard problem in term of API design. For instance we could try to remove the GraphQL line query getTopic ($topicId: ID) {
and have Apollo generate this wrapper automatically with the informations from the JavaScript world, but that’s wouldn’t be suitable because the abstraction doesn’t match in a 1..1 relation (JS doesn’t have types for instance). I just wanted to emphasize this problem because other similar in-scope libraries like OM/next doesn’t encounter it. They use the same language (ClojureScript) for the wrapper file and the query specification, and that allows them to create a perfect API with no duplicate symbols.
So back to my above example, I still think that there is room for improvements, and I’ll try to iterate on it but I may be wrong at some specific step so my goal is more to open a discussion than to provide a concrete proposal.
The first thing I would like to remove is the topic
JavaScript variable as I believe we could rely exclusively on the GraphQL names as we do for non-top-levels names like category_id
or title
. Since in this specific example the JS name (topic
) and the GQL name (oneTopic
) doesn’t match we need to rename the field in GraphQL:
const TopicHeader = connect({
mapQueriesToProps({ ownProps }) {
return {
query: gql`
query getTopic ($topicId: ID) {
topic: oneTopic(id: $topicId) {
id
category_id
title
}
}
`,
variables: {
topicId: ownProps.topicId,
}
}
},
})(RawTopicHeader);
The rename really isn’t anything fancy, it’s what we would do for any inner GraphQL field anyway, so it’s more consistent to do it for this top-level name as well. Removing the symbol in the JS space also doesn’t remove anything in term of code editor features because the symbol will be exposed as a component argument as it was prior to this change:
const RawTopicHeader = ({ topic }) => {
return (<div><h1>{topic.title}</h1></div>)
}
Generally speaking I think that using function arguments as the fence between JS and GQL symbols would be a useful API principle.
The next step might be more controversial but I believe there is some value is switching to a static query instead of dynamically computing it—which should be exclusive to the “variables” part. Static data requirements would be useful to various dev tools and to compose queries without instantiating the related components. So basically we would write something like this:
const TopicHeader = exposeGQL({
query: gql`
query getTopic ($topicId: ID) {
topic: oneTopic(id: $topicId) {
id
category_id
title
}
}
`,
variables: function(ownProps) {
return {
topicId: ownProps.topicId,
};
}
})(RawTopicHeader);
Only the variables
part is computed, the query
is static. I’ll do a little digression to address the (rare?) cases where the data specification depends on some component props, for instance:
const UserName = connect({
mapQueriesToProps({ ownProps }) {
if (ownProps.anonymousUser) {
return {};
} else {
return {
query: gql`
query getUser ($userId: String!) {
user(id: $userId) {
name
}
}
`,
variables: {
userId: ownProps.userId,
}
}
}
},
})(RawUserName);
Here if the user is anonymous we don’t want to send a query to the server asking the user name because we already now that this information doesn’t exist. I can think of two solutions to handle this case:
-
Use the @skip
and @include
directives, that would be something like
const Username = exposeGQL({
query: gql`
query getUser ($skipFetching: Boolean!, $userId: String!) {
user(id: $userId) @skip(if: $skipFetching) {
name
}
}
`,
variables: function(ownProps) {
return {
skipFetching: ownProps.anonymousUser
userId: ownProps.userId,
};
}
})(RawUsername);
I’m not super fan of this solution as it sounds a bit like cheating: instead of writing a simple if
condition, we have to introduce a weird GraphQL directive to express statically that’s we want to skip a query when executed;
-
Another possibility would be to not specify that we want to skip the query fetching and let the Apollo client figuring that out for us, as follows:
const Username = exposeGQL({
query: gql`
query getUser ($userId: String!) {
user(id: $userId)
name
}
}
`,
variables: function(ownProps) {
return {
userId: ownProps.anonymousUser ? null : ownProps.userId,
};
}
})(RawUsername);
Here we don’t explicitly say that we want to skip the user fetching, but as we don’t pass a valid userId
to the GraphQL query (we are passing null
whereas a string is expected), there is no way the GraphQL server will return a user from that invalid query and so the Apollo client could avoid the query roundtrip. Consequently the user
will be undefined
in the UI component, which is what we want in this case.
I don’t want to expend too much on this particular issue of expressing dynamic requirements with static queries (it’s already a big parenthesis), there are probably many other solutions and I believe that a majority (all?) of UI components could express their data requirements in a static way.
Back to the original example, here is the code as we left it before the digression:
const TopicHeader = exposeGQL({
query: gql`
query getTopic ($topicId: ID) {
topic: oneTopic(id: $topicId) {
id
category_id
title
}
}
`,
variables: function(ownProps) {
return {
topicId: ownProps.topicId,
};
}
})(RawTopicHeader);
To avoid repeating the world query
twice, we could simply switch to ordered function arguments. The first argument is the GraphQL query, the second one is the variables mapping—like this:
const TopicHeader = exposeGQL(gql`
query getTopic ($topicId: ID) {
topic: oneTopic(id: $topicId) {
id
category_id
title
}
}`,
function(ownProps) {
return {
topicId: ownProps.topicId,
};
}
)(RawTopicHeader);
and for stylistic concision only, we would use an ES6 arrow function for the variables mapping:
const TopicHeader = exposeGQL(gql`
query getTopic ($topicId: ID) {
topic: oneTopic(id: $topicId) {
id
category_id
title
}
}`,
(props) => ({
topicId: props.topicId,
})
)(RawTopicHeader);
At this point we already gained a lot of concision, one last step (that is maybe too much?) would be to make the second argument (the mapping) optional by providing a default value: the identity function ((props) => props
) that would expose the components props to the GraphQL query variables. Thus, the mapping function would be skipped for the most simple components. In our case:
const TopicHeader = exposeGQL(gql`
query getTopic ($topicId: ID) {
topic: oneTopic(id: $topicId) {
id
category_id
title
}
}
`)(RawTopicHeader);
And that’s it, I’m pretty happy with this last snippet :-)
I’m sorry for the very long text, I thought it was useful to share my thought process to facilitate the discussion about potential API simplifications.