Back to blogs
Blog
Writing in public
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Blog
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Back to blogs
Back to blogs
Postrelay, graphql, react, webdev
Why GraphQL and Relay for your React app? Building complex UIs that fetch data efficiently...
Date published

Why You Might Still Pick Next.js Over TanStack Start From a TanStack Start...

A complete theme management system for React applications with SSR support! ✨ 🌟...

slides used in the presentation building the generic component In this example we'll use...
Building complex UIs that fetch data efficiently can be a challenge. That's where GraphQL and Relay come in, offering a powerful combination for your React application.

Here's why you should consider them:
Reasons for GraphQL:

Compared to other clients:
Ultimately, the choice depends on your project's needs. If you value performance, type safety, and a declarative approach, Relay and GraphQL are a powerful duo for building scalable and maintainable React applications.
But like everything else, GraphQL and Relay have their own strengths and weaknesses.
Some notable pain points include
>some of the code snippets below were AI generated for use as pseudo code , tweaks might be required to get them working
1export const RepositoriesFragment = graphql`2 fragment ViewerRepos_repositories on User3 @argumentDefinitions(4 first: { type: "Int", defaultValue: 10 }5 after: { type: "String" }6 orderBy: {7 type: "RepositoryOrder"8 defaultValue: { field: PUSHED_AT, direction: DESC }9 }10 isFork: { type: "Boolean", defaultValue: false }11 )12 @refetchable(queryName: "RepositoriesPaginationQuery") {13 repositories(14 first: $first15 after: $after16 orderBy: $orderBy17 isFork: $isFork18 ) @connection(key: "Repositories_repositories") {19 totalCount20 edges {21 node {22 id23 name24 nameWithOwner25 }26 }27 pageInfo {28 endCursor29 hasNextPage30 hasPreviousPage31 }3233 }34}
vs Apollo equivalent
1export const REPOSITORIES_QUERY = gql`2query {3 viewer {4 repositories(first: 10) {5 totalCount6 edges {7 node {8 id9 name10 nameWithOwner11 }12 }13 pageInfo {14 endCursor15 hasNextPage16 hasPreviousPage17 }18 }19 }20}21`
like
1import { useQuery, gql } from '@apollo/client';23function Repositories() {4 // Fetch the first 10 repositories5 const { loading, error, data, fetchMore } = useQuery(REPOSITORIES_QUERY, {6 variables: { first: 10 },7 });89 if (loading) return <p>Loading...</p>;10 if (error) return <p>Error :(</p>;1112 const { edges, pageInfo } = data.viewer.repositories;1314 return (15 <div>16 <h3>Repositories</h3>17 <ul>18 {edges.map(({ node }) => (19 <li key={node.id}>20 {node.nameWithOwner}21 </li>22 ))}23 </ul>24 {pageInfo.hasNextPage && (25 <button26 onClick={() =>27 fetchMore({28 variables: {29 first: 10,30 after: pageInfo.endCursor,31 },32 })33 }34 >35 Load more36 </button>37 )}38 </div>39 )40}41
and on mutation you'd have to manually update the cache of nested fields to inject the response from the mutation response
For example, if you have a mutation that adds a new repository to the viewer's list, you can use the update function to insert the new repository into the cache, like this:
1import { useMutation, gql } from '@apollo/client';23const ADD_REPOSITORY = gql`4 mutation AddRepository($name: String!) {5 createRepository(input: { name: $name, visibility: PUBLIC }) {6 repository {7 id8 name9 nameWithOwner10 }11 }12 }13`;1415function AddRepository() {16 let input;17 const [addRepository, { data, loading, error }] = useMutation(ADD_REPOSITORY);1819 if (loading) return 'Submitting...';20 if (error) return `Submission error! ${error.message}`;2122 return (23 <div>24 <form25 onSubmit={(e) => {26 e.preventDefault();27 addRepository({28 variables: { name: input.value },29 update: (cache, { data: { createRepository } }) => {30 // Read the query for the viewer's repositories31 const data = cache.readQuery({32 query: REPOSITORIES_QUERY,33 variables: { first: 10 },34 });35 // Insert the new repository into the cache36 cache.writeQuery({37 query: REPOSITORIES_QUERY,38 variables: { first: 10 },39 data: {40 ...data,41 viewer: {42 ...data.viewer,43 repositories: {44 ...data.viewer.repositories,45 edges: [46 ...data.viewer.repositories.edges,47 {48 __typename: 'RepositoryEdge',49 node: createRepository.repository,50 },51 ],52 },53 },54 },55 });56 },57 });58 input.value = '';59 }}60 >61 <input62 ref={(node) => {63 input = node;64 }}65 />66 <button type="submit">Add Repository</button>67 </form>68 </div>69 );70}
while in relay the mutation would be much simpler and the cache update would be handled automatically
1 const [commit, isInFlight] = useMutation<AddRepositoryMutation>(ADD_REPOSITORY);
This pain point can be ignored because it sets you up for an easier experienced own the road
Skill issues I overcame:: Some of the issues I had with Relay initially just boiled down to skill issues around React and Typescript
usePaginatedFragment1 const frag_data = usePaginationFragment<Fragment_name$data,any>(SomeFragment, refs);2 const some_fragment = frag_data.data as Fragment_name$data;3
Doing an as type casting felt wrong and rightly so. because the fix was so simple
1 const frag_data = usePaginationFragment<MainQuery,Fragment_name$key>(SomeFragment, ref);2 const some_fragment = frag_data.data34
Relay auto generates Fragment_name$key and Fragment_name$data types , the Fragment_name$key is what should be passed into the usePaginationFragment hook and the Fragment_name$data is what the actual fragment will be of type of , it's not supposed to be used directly inside the hooks.
Also note the paginated fragment takes in the Fragment_name$key as it's second type parameter unlike the useFragment hook that only accepts one type parameter where we pass in Fragment_name$key
1 const frag_data = useFragment<Fragment_name$key>(SomeFragment, ref);2 const paginated_frag_data = usePaginationFragment<MainQuery,Fragment_name$key>(SomeFragment, ref);
The MainQuery type is the type for the main query that the fragment is part of
1export const mainQuery = graphql`2 query MainQuery() {3 stuff {4 ...Fragment_name5 }6 }7`;
1 const query = useLazyLoadQuery<MainQuery>(mainQuery)
this query then becomes a ref that should be passed into the fragment query hooks as the second argument the first argument being the fragment
1export const SomeFragment = graphql`2 fragment Fragment_name on Stuff {3 edges {4 node {5 id6 name7 createdAt8 }9 }1011 }12`
1 const frag_data = usePaginationFragment<MainQuery,Fragment_name$key>(SomeFragment, ref);2 const some_fragment = frag_data.data
which leads me to another accidental discovery I made while figuring out a way to pass the refs into the fragment components with the correct types , a typescript helper type FragmentRef is exposed by relay
1 refs?: {2 readonly " $fragmentSpreads": FragmentRefs<3 | "Fragment_name"4 | "Fragment_history"5 >;6 } | null;
Will have a type we can pass into a component that houses the components for Fragment_name and Fragment_history avoiding having to use the any type
Relay will return all query types and read only and this might become a problem if you have a query result that returns an array of
1type OneItem = {2id3name4createdAt5}
Normally if you wanted to have an ItemCard component you would simply
1type ItemList = OneItem[]2{items.mao((item) => (3 <ItemCard key={item.id} item={item} />4))}5type ItemCardItem = ItemList[number]67finction ItemCard({ item }:ItemCardItem) {8 return <div>{item.name}</div>;9}
But indexing with a number is not allowed with readonly arrays in Typescript
1type Items = ReadOnlyArray<ItemList>2// ❌ not allowed3type ItemCardItem = ItemList[number]
So i made a helper type to convert ReadOnlyArray to Array
1type ReadonlyToRegular<T> = T extends ReadonlyArray<infer U> ? Array<U> : never;2type Items = ReadOnlyArray<ItemList>3type ItemCardItem = ReadonlyToRegular<ItemList>[number]
1<!-- ❌ -->2 function SomeList() {3 const { loading, error, data } = useQuery(SOME_QUERY, {4 variables: { first: 10 },5 })6 return(7 <Suspense fallback={<div>Loading...</div>}>8 <div>This is a data fetching component</div>;9 </Suspense>10 )11 }12
1<!-- ✅ -->2function ParentComponent() {3 return(4 <Suspense fallback={<div>Loading...</div>}>5 <SomeList />6 </Suspense>78 )910}11 function SomeList() {12 const { loading, error, data } = useQuery(SOME_QUERY, {13 variables: { first: 10 },14 })15 return(16 <div>This is a data fetching component</div>;17 )18 }19
useTransition I had a search component which would make a bunch or request while one is typing which would trigger the suspense boundary of the parent component covering the whole page the search box included ,one possible work around could have been to hoist the input and the associated useState and pass in the current keyword to the SearchResults component which would also house the data fetching logic and wrap that with a suspense boundary . Or we could wrap the setState with a startTransition to mark the key inputs as more important and render everything else in the background and show the results when ready without a suspense boundary.1 const [, startTransition] = useTransition();2 const [keyword, setKeyword] = useState("");34 const { loading, error, data } = useQuery(SOME_QUERY, {5 variables: { query: keyword, first:19 },67 })89 return(10 <div>11 <input value={keyword}12 <!-- ❌ will cause flickers13 onChange={(e) => {14 setKeyword(e.target.value)1516 }} -->17 <!-- ✅ -->18 onChange={(e) => {19 startTransition(() => {20 setKeyword(e.target.value)21 })22 }}23 />2425 <Suspense fallback={<div>Loading...</div>}>26 <SearchResults data={data} />27 </Suspense>28 </div>29 )3031
As an addition i also relied on the URL and serach params to store the variables , makes shring URls eas and state is still maitatined after a refresh
1export function useDebouncedValue<T = any>(value: T, delay: number) {2 const [isDebouncing, setIsDebouncing] = useState(false);3 const [debouncedValue, setDebouncedValue] = useState<T>(value);45 useEffect(() => {6 setIsDebouncing(true);7 const timer = setTimeout(() => {8 setDebouncedValue(value);9 setIsDebouncing(false);10 }, delay);1112 return () => {13 clearTimeout(timer);14 };15 }, [value, delay]);1617 return { debouncedValue, setDebouncedValue,isDebouncing };18}192021import { useTransition, useState, useEffect } from "react";22import { navigate, useLocation } from "rakkasjs";23import { SearchType } from "./__generated__/SearchListQuery.graphql";2425export function useGithubSearch() {26 const { current } = useLocation();27 const initSearchType = current.searchParams.get("st") as SearchType | null;28 const initSearchValue = current.searchParams.get("sq") ?? "";2930 const [, startTransition] = useTransition();31 const { debouncedValue, setDebouncedValue, isDebouncing } = useDebouncedValue(32 initSearchValue,33 5000,34 );35 const [searchType, setSearchType] = useState<SearchType>(36 initSearchType ?? "REPOSITORY",37 );38 useEffect(() => {39 if (debouncedValue !== initSearchValue) {40 setDebouncedValue(initSearchValue);41 }42 }, []);43 useEffect(() => {44 const new_url = new URL(current);45 if (debouncedValue && debouncedValue !== initSearchValue) {46 new_url.searchParams.set("sq", debouncedValue);47 }48 if (searchType && searchType !== initSearchType) {49 new_url.searchParams.set("st", searchType);50 }51 startTransition(() => {52 navigate(new_url.toString());53 });54 }, [debouncedValue, searchType]);5556 return {57 debouncedValue,58 setDebouncedValue,59 isDebouncing,60 searchType,61 setSearchType,62 startTransition,63 current,64 };65}66
It's still awesome though: With all that said relay is still awesome , so awesome it inspired the React server components and the best way to do GraphQL in react