Yew Hooks with GraphQL
February 3, 2022โข1,449 words
Over the last year or so I've been occasionally hacking away at a web app called Dicebag, which will eventually become a collection of useful tools to facilitate in-person Dungeons & Dragons games.
Part of this project stems from my lack of satisfaction with other tools I've found. Most tend to focus on running a game online or preparing for games in advance. I'm wanting something that enhances the player and DM experience by presenting contextual data depending on what's happening in the game, keeping players off their phones and engaged in the story.
I'm a React developer by trade but a Rustacean at heart, so I decided to write it using the Yew framework, one of the more popular Rust web frameworks. It's been really fun so far! The app is ugly and non-functional except for a janky initiative tracker I just put in place, and even that is far from polished.
Regardless of the messy code and unpolished UI/UX, it felt great to put together a useful, generic custom hook for making GraphQL requests using Yew and the Rust graphql-client crate.
This post is a short walk-through on the anatomy of my custom GraphQL hook and ways I'd further like to improve it.
So, let's take a look at the hook! The code below is heavily annotated with comments I've added for the purposes of this blog post to explain Rust concepts, the libraries I'm using, or things I'm particularly happy with!
First up, the example GraphQL query we'll be working with:
# Query to fetch a campaign by ID. If none are provided, return all campaigns
query CampaignsQuery($campaign_id: Int) {
campaigns(id: $campaign_id) {
id
name
description
}
}
Now an example usage of the use_query
hook:
// Example usage of the campaigns query within a Yew functional component
#[function_component(CampaignsPage)]
pub fn campaigns_page() -> Html {
let variables = campaigns_query::Variables { campaign_id: 1 };
// I'm particularly happy with the user experience on this hook.
// All you have to do is choose the query you want to make by specifying
// the generic parameter's struct and pass in the variables for that query.
// Can't get much simpler than that!
let query = use_query::<CampaignsQuery>(variables);
// ... use the query results to display campaign #1
}
And finally, the hook code itself:
// The code for the use_query hook
// `graphql-client` crate builds all the types for you just by looking at the
// GraphQL server schema (which is auto-generated with a CLI command)
// and the query you wrote (which was the first code block in this post)
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/graphql/schema.json",
query_path = "src/graphql/queries.graphql",
response_derives = "Clone"
)]
pub struct CampaignsQuery;
#[derive(Clone)]
pub struct QueryResponse<T> {
pub data: Option<T>,
pub error: Option<String>,
}
// The query itself! There are three trait bounds, all related to the
// graphql-client crate types. The `Clone` and `'static` bits are needed
// to fulfill the lifetime requirements of the data here, since this is
// going to be used with in the context of a Yew functional component
pub fn use_query<Q>(variables: Q::Variables) -> QueryResponse<Q::ResponseData>
where
Q: GraphQLQuery, // GraphQLQuery is the trait provided by the graphql-client crate
Q::Variables: 'static, // That trait also provides a way to specify the variables
Q::ResponseData: Clone + 'static, // And the type you expect to get back
{
// Local state to keep track of the API request, used to eventually
// return the results to the user
let state = use_state(|| QueryResponse {
data: None,
error: None,
});
// Now we get to the part of Yew that isn't so nice. I've got to clone
// the state so I can move it into an asynchronous thread, since Yew hooks
// can't do async without spinning up a local thread
let effect_state = state.clone();
// This works identically to React's `useEffect` function
use_effect_with_deps(
move |_| {
// As stated earlier, we spin up a thread in order to use
// the asynchronous API call code
spawn_local(async move {
// `build_query` is another nicety provided by the GraphQLQuery type
let request_body = Q::build_query(variables);
let request_json = &json!(request_body);
// reqwest is a nice Rust http client
let request = reqwest::Client::new()
.post("http://my-server.domain.com")
.json(request_json)
.send()
.await;
// Set the data or errors as the results dictate
match request {
Ok(response) => {
// Turn the response JSON into the expected types
let json = response.json::<Response<Q::ResponseData>>().await;
match json {
Ok(response) => effect_state.set(QueryResponse {
data: response.data,
error: None,
}),
Err(error) => effect_state.set(QueryResponse {
data: None,
error: Some(error.to_string()),
}),
}
}
Err(error) => effect_state.set(QueryResponse {
data: None,
error: Some(error.to_string()),
}),
}
});
// The "cleanup" function, just like in React's `useEffect`
// Since there's nothing to cleanup here, we write an empty function
|| ()
},
// The `useEffect` dependency here is `()`, the unit type, which is
// equivalent to passing `[]` in React's `useEffect`
(),
);
// Return the state's value to the user so they can use the API result!
(*state).clone()
}
Isn't that cool? It has a simple API that I'm excited to use. Writing it felt similar to React with some pain points that come from Yew being a developing framework and the verbosity type system in Rust, but I'm quite enjoying the development process in this tech stack.
Writing the hook took me a few iterations to get the API right, since I'd never written much Rust code dealing with generics and trait bounds. In fact, as of time of this writing you can see at least one older version still in the codebase because I haven't migrated everything over to the new and improved one yet.
Initially I had my own Response
and Query
types with weird lifetimes that were annoying to write and use because I didn't understand that I could dig into the ResponseData
type on the generic Q
trait with the GraphQLQuery
bound. Going through this exercise forced me to better understand lifetimes, Clone
, and generics, so I'm happy I spent the time iterating on it.
Potential Improvements
loading
Field
Some GraphQL hook libraries provide a loading
field on the data structure so you can tell if you're still waiting on the API. I'm conflicted on adding this, since you can discover if the API has returned by checking if data
or errors
is a Some
value.
But it's not hard to add and simplifies if
statements for users of the hook so I'll probably add it in once start using the hook more heavily and feel that annoyance myself.
Improved Errors
Right now I'm just smashing the errors into a string. Ideally I'd return them in a structured manner, but I just haven't gotten to that yet.
Refreshing the Query
Given that the use_effect_with_deps
has a ()
as its dependency, this query will only run on the first time the component using it renders.
Ideally I would have better control over when the query refreshes, especially in scenarios where you add something new and want the UI to update. It might be easier to just pair it with another hook that lets you refresh the whole component, or maybe it's a new parameter to the query.
Time will tell. I'm not nearly close enough to caring about that kind of thing in the Dicebag app yet!
Support For Any GraphQL Client
Right now it only works with the structs produced by the graphql-client
crate. That's what I use in my project, but if I were to export this hook for general use it would be nice to switch up the types as needed. I'm not even sure I can make the hook that generic, but it would be a useful learning opportunity to stretch the bounds of generics until they break.
Conclusion
Yew's hooks are fun! Writing my own taught me a lot more about Yew as a framework, generics, trait bounds, lifetimes, Rc
s, and more.
Yew is still developing as a framework, but I'm excited to see where it goes. It already rivals React and other top JS frameworks in terms of speed, and that's with a small volunteer community working on it. WASM has a bright future, and because of that, Yew has an opportunity to play a big part in the Rust web development space. I enjoy working with it so much that I'm hoping to contribute to the project myself. And if I'm lucky, maybe I'll even get paid to write Rust on the front-end someday!
If you have any feedback regarding the hook or this post, feel free to open an issue on my repository or reach out to me on the social media platforms on my About Me page!