Upgrade to Pro — share decks privately, control downloads, hide ads and more …

(why the hell did I) build a GraphQL client for...

(why the hell did I) build a GraphQL client for the browser

Exploring why and how I ended up building @swan-io/graphql-client. From its exposed API to its cache engine, with a few extras.

Matthias Le Brun

December 05, 2024
Tweet

More Decks by Matthias Le Brun

Other Decks in Technology

Transcript

  1. our product is a GraphQL API* * and some frontend

    interfaces I promise my job is real
  2. but existing clients … → designed for a single schema

    → caching requires introspection → caching issues anyway → impact on bundle size → hard to extend/contribute
  3. «how hard can it be to build a GQL client

    from scratch?» — me, clueless about how hard it can be to build a GQL client from scratch
  4. «how hard can it be to build a GQL client

    from scratch?» — me, clueless about how hard it can be to build a GQL client from scratch
  5. current state of the ecosystem API const UserProfile = ()

    => { const { loading, data, error } = useQuery(query, {}); // ... };
  6. error handling in GraphQL ambiguities { "status": null } +

    Create your fi rst status Error loading status
  7. error handling in GraphQL ambiguities if you want to guarantee

    that the UI is consistent with the data, query success is all or nothing
  8. API @swan-io/boxed type Option<T> = Some<T> | None<T>; type Result<T,

    E> = Ok<T> | Error<E>; type AsyncData<T> = | NotAsked<T> | Loading<T> | Done<T>; open-source!
  9. usage API const MyComponent = () => { const [data]

    = useQuery(query, {}); return match(data) .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => <LoadingView /> ) .with(AsyncData.P.Done(Result.P.Error(P.select())), (error) => ( <ErrorView error={error} /> )) .with(AsyncData.P.Done(Result.P.Ok(P.select())), (data) => { // show your data }) .exhaustive(); };
  10. usage API const UserPage = ({ userId }: Props) =>

    { const [updateUsername, usernameUpdate] = useMutation(updateUsernameMutation); const [username, setUsername] = useState(""); const onSubmit = (event) => { event.preventDefault(); updateUsername({ userId, username }); }; const isLoading = usernameUpdate.isLoading(); // ... };
  11. engine → levels of caching → operation cache API render

    query result cache if query<variables> not in cache
  12. engine → levels of caching → operation cache API render

    query result cache → returns cache if available → calls API systemically to refresh stale while revalidate
  13. engine → levels of caching → operation cache API render

    mutation result mutation invalidation
  14. engine → levels of caching → operation cache API render

    mutation result mutation invalidation API render query result cache prime manual refresh
  15. query typename export const addTypenames = (documentNode: DocumentNode): DocumentNode =>

    { return visit(documentNode, { [Kind.SELECTION_SET]: (selectionSet): SelectionSetNode => { if ( selectionSet.selections.find( (selection) => selection.kind === Kind.FIELD && selection.name.value === "__typename", ) ) { return selectionSet; } else { return { ... selectionSet, selections: [TYPENAME_NODE, ... selectionSet.selections], }; } }, }); };
  16. engine caveat the client expects you query IDs systematically eslint

    plugin or codegen transforms can do that for you
  17. engine what's a cache entry? anything else needs to be

    cached in its closest cached ancestor entry
  18. engine cache structure Map { Symbol(Query) => { Symbol(user({"id":1})) =>

    Symbol(User<1>), } Symbol(User<1>) => { Symbol(id) => 1, Symbol(username) => "bloodyowl" } }
  19. engine cache structure Map { Symbol(Query) > Symbol(user({"id":1})) > }

    Symbol(User<1>) > Symbol(id) > Symbol(username) > } } Symbol(Query) Symbol(User<1>) → entries
  20. engine cache structure Map { Symbol(Query) > Symbol(user({"id":1})) > }

    Symbol(User<1>) > Symbol(id) > Symbol(username) > } } Symbol(user({"id":1})) Symbol(id) Symbol(username) → fi elds with their arguments
  21. engine cache structure Map { Symbol(Query) > Symbol(user({"id":1})) > }

    Symbol(User<1>) > Symbol(id) > Symbol(username) > } } Symbol(User<1>), → pointers to cache entries
  22. engine cache: respond to query how do you know if

    the cache has enough data to return a result while the API is loading?
  23. engine cache structure Symbol(User<1>) > Symbol(__requestedKeys) > Symbol(id) > Symbol(username)

    > } Symbol(__requestedKeys) => Set { Symbol(id), Symbol(username) } → track fi elds that have been requested cache can respond if all requested fi elds are cached
  24. engine → ambiguities → interfaces . __typename id amount {

    value currency } } { "__typename": "CardTransaction", "id": "efea560c-1a26-4d31-9555-ac5714 "amount": { "value": "10", "currency": "EUR" } } Transaction "CardTransaction" → can't assume fragment name as cache key we need to provide the cache the interface → object map
  25. extras relay pagination const [data, { isLoading, setVariables }] =

    useQuery( AllFilmsQuery, { first: 3 }, ); if a variable changes, data goes back to loading
  26. extras relay pagination const [data, { isLoading, setVariables }] =

    useQuery( AllFilmsQuery, { first: 3 }, ); if a variable changes, data remains Done, isLoading gets true
  27. engine cache structure Map { Symbol(Query) => { Symbol(user({"after":null})) =>

    { ... }, Symbol(user({"after":"1az2zae ... "})) => { ... }, } }
  28. extras connection updates useMutation(BlockUser, { connectionUpdates: [ ({ data, append

    }) => Option.fromNullable(data.blockUser).map(({ user }) => append(blockedUsers, [user]), ), ({ data, prepend }) => Option.fromNullable(data.blockUser).map(({ user }) => prepend(lastBlockedUsers, [user]), ), ], });
  29. extras optional optimization const [data] = useQuery( MyQuery, {}, {

    optimize: true }, ); → creates an optimized query, only requests missing fi elds → doesn't query if everything in cache