Apollo is great, but it is a bit like IKEA furniture - you have to assemble it yourself.
Showcasing internal tooling we build at Product Hunt to make our life easier when dealing with GraphQL and Apollo.
Building tools on top ofRadoslav Stankov
View Slide
Radoslav Stankov@rstankov rstankov.comblog.rstankov.com twitter.com/rstankov github.com/rstankov
https://rstankov.com/appearances
Plan for today
1.What is GraphQL2.What is Apollo3.Tools on top of ApolloPlan for today
https://graphql.org/
GraphQL is an open-source data query and manipulationlanguage for APIs, and a runtime for fulfilling queries withexisting data.
Query ⬅Mutation ➡
Query ⬅
query Homepage {homefeed {edges {node {idtitleitems {idtitletaglineurlthumbnailvotesCounthasViewerVoted}}}pageInfo {hasNextPageendCursor}}}
{"data": {"homefeed": {"edges": [{"node": {"id": "day1","title": "Day 1","items": {"id": 1,"title": "Post 1","tagline": "Tagline","url": "https://example.com/1","thumbnail": "https://example.com/1.png","votesCount": 123,"hasViewerVoted": false}}},{"node": {"id": "day2","title": "Day 2",
},{"node": {"id": "day2","title": "Day 2","items": {"id": 1,"title": "Post 2","tagline": "Tagline","url": "https://example.com/2","thumbnail": "https://example.com/2.png","votesCount": 23,"hasViewerVoted": true}}}],"pageInfo": {"hasNextPage": true,"endCursor": "day3"}}}}
Mutation ➡
mutation PostVoteCreate($input: PostVoteCreateInput!) {postVoteCreate(input: $input) {node {idvotesCounthasViewerVoted}errors {fieldmessages}}}
{"data": {"postVoteCreate": {"node": {"id": "1","votesCount": 1129,"hasViewerVoted": true},"errors": []}}}
import { gql, useQuery } from '@apollo/client';const QUERY = gql`query Homepage {// ...}`;function Homefeed() {const { loading, error, data } = useQuery(QUERY);if (loading) {return Loading...;}if (error) {return Error...;}return (Homefeed{data.homefeed.edges(({ node }) => ({node.title}
function Homefeed() {const { loading, error, data } = useQuery(QUERY);if (loading) {return Loading...;}if (error) {return Error...;}return (Homefeed{data.homefeed.edges(({ node }) => ({node.title}{node.items.map((item) => item.title)}))});}
import { gql, useMutation } from '@apollo/client';const CREATE_MUTATION = gql`mutation PostVoteCreate($input: PostVoteCreateInput!){ /* ... */ }`const UPDATE_MUTATION = gql`mutation PostVoteUpdate($input: PostVoteUpdateInput! { /* ... */ }`function VoteButton({ post }) {const [vote] = useMutation(CREATE_MUTATION);const [unvote] = useMutation(UPDATE_MUTATION);const onClick = () => {if (post.hasViewerVoted) {unvote({ variables: { postId: post.id } });} else {vote({ variables: { postId: post.id } });}};return {post.votesCount};}
Generates TS Type definitionsfrom GraphQL queries
apollo client:codegen \--localSchemaFile="graphql/schema.json" \--addTypename \--tagName=gql \--target=typescript \--includes="{components,screens,utils,hooks,layouts}/**/*.{tsx,ts}" \--outputFlat="graphql/types.ts"
components/Profile/Avatar/Fragment.tsimport gql from 'graphql-tag';export default gql`fragment ProfileAvatarFragment on Profile {idnamekindimageUrl}`;
// ====================================================// GraphQL fragment: ProfileAvatarFragment// ====================================================export interface ProfileAvatarFragment {__typename: "Profile";id: string;name: string;kind: string;imageUrl: string | null;}graphql/types.ts
import { ProfileAvatarFragment } from '~/graphql/types';
Tools on top of Apollo
What are common things to havein an app?
⛷ Pagination ☎ Buttons % Forms
⛷ Pagination
fetchMore({variables: {cursor: endCursor,},});
Type Policyhttps://www.apollographql.com/docs/react/caching/cache-configuration/#typepolicy-fields
import { relayStylePagination } from '@apollo/client/utilities';export default {Query: {fields: {homefeed: relayStylePagination(['key']),// ...},},User: {fields: {posts: relayStylePagination([]),// ...},},// ...};
function useLoadMore({ connection, fetchMore, cursorName }) {const [isLoading, setIsLoading] = useState(false);const ref = useRef({});ref.current.isLoading = isLoading;ref.current.setIsLoading = setIsLoading;ref.current.fetchMore = fetchMore;React.useEffect(() => {ref.current.isMounted = true;return () => { ref.current.isMounted = false; };}, [ref]);const loadMore = useCallback(async () => {if (ref.current.isLoading || !connection.pageInfo.hasNextPage) {return;}ref.current.setIsLoading(true);await ref.current.fetchMore({variables: {[cursorName || 'cursor']: connection.pageInfo.endCursor,},
ref.current.isMounted = true;return () => { ref.current.isMounted = false; };}, [ref]);const loadMore = useCallback(async () => {if (ref.current.isLoading || !connection.pageInfo.hasNextPage) {return;}ref.current.setIsLoading(true);await ref.current.fetchMore({variables: {[cursorName || 'cursor']: connection.pageInfo.endCursor,},});if (ref.current.isMounted) {ref.current.setIsLoading(false);}}, [connection, ref]);return { loadMore, isLoading, hasMore: connection.pageInfo.hasNextPage };}
export function LoadMoreWithButton(options: ILoadMoreArgs) {const { loadMore, isLoading, hasMore } = useLoadMore(options);if (isLoading) {return Loading...;}if (hasMore) {return Load more;}return null;}
export default function LoadMoreWithScroll(options: ILoadMoreArgs) {const { loadMore, isLoading, hasMore } = useLoadMore(options);if (!hasMore) {return null;}return (<>key={options.connection.pageInfo.endCursor || 'cursor'}bottomOffset="-20%"onEnter={loadMore}fireOnRapidScroll={true}/>{isLoading && }>);}
☎ Buttons
mutation={MUTATION}input={{ postId: post.id }}/>mutation={MUTATION}input={{ postId: post.id }}optimisticResponse={{node: {id: post.id,votesCount: post.votesCount + 1,hasVoted: true },}}/>mutation={MUTATION}input={{ postId: post.id }}onMutate={() => alert('Thanks for your vote &')}/>
export default function Button(props) {const onClick = useOnClick(props);const Element: any = props.to ? Link : 'button';return (disabled={props.disabled}to={props.to}type={props.to ? undefined : props.type}target={props.target}data-test={props.dataTest}className={classNames(styles.reset, props.className)}onClick={props.onClick}rel={props.rel}>{children});}
Button.Primary = ({ title, className, active = false, ...props }) => ({...props}className={classNames(styles.primary, active && styles.active, className)}>{title});Button.Secondary = ({ title, className, active = false, ...props }) => ({...props}className={classNames(styles.secondary, active && styles.active, className}>{title}); Button.Small = ({ title, className, active = false, ...props }) => ({...props}className={classNames(styles.small, active && styles.active, className}>{title});
interface IOnClickOptions {onClick?: (e: IEvent) => void | Promise;requireLogin?: boolean | string;disabled?: boolean;confirm?: string;mutation?: DocumentNode;input?: DefaultObject;onMutate?: (node: IResponse) => void;onMutateError?: (errors: DefaultObject) => void;optimisticResponse?: any;update?: (cache: any, result: any) => void;updateQueries?: any;refetchQueries?: { query: any; variables?: any }[];}export default function useOnClick(options: IOnClickOptions) {const ref = React.useRef(null);ref.current = {...options,isLoading: false,isMounted: true,} as IRef;React.useEffect(() => {
export default function useOnClick(options: IOnClickOptions) {const ref = React.useRef(null);ref.current = {...options,isLoading: false,isMounted: true,} as IRef;React.useEffect(() => {ref.current!.isMounted = true;return () => {ref.current!.isMounted = false;};}, [ref]);return React.useCallback((e: IEvent) => runOnClick(ref.current!, e), [ref,]);}async function runOnClick(options: IRef, e: IEvent) {if (options.confirm && !(await window.confirm(options.confirm))) {return;
export default function useOnClick(options: IOnClickOptions) {const ref = React.useRef(null);ref.current = {...options,isLoading: false,isMounted: true,} as IRef;React.useEffect(() => {ref.current!.isMounted = true;return () => {ref.current!.isMounted = false;};}, [ref]);return React.useCallback((e: IEvent) => runOnClick(ref.current!, e), [ref,]);}async function runOnClick(options: IRef, e: IEvent) {if (options.disabled || options.isLoading) {return;
async function runOnClick(options: IRef, e: IEvent) {if (options.disabled || options.isLoading) {return;}if (options.requireLogin && !isLoggedIn()) {return openLoginFullscreen(options.requireLogin);}if (options.confirm && !(await window.confirm(options.confirm))) {return;}options.isLoading = true;if (options.onClick) {await options.onClick(e);}if (options.mutation) {const { node, errors } = await executeMutation({mutation: options.mutation,input: options.input || {},update: options.update,optimisticResponse: options.optimisticResponse,
}if (options.mutation) {const { node, errors } = await executeMutation({mutation: options.mutation,input: options.input || {},update: options.update,optimisticResponse: options.optimisticResponse,updateQueries: options.updateQueries,refetchQueries: options.refetchQueries,});if (node) {options.onMutate?.(node);}if (errors) {options.onMutateError?.(errors);}}if (options.isMounted) {options.isLoading = false;}}
mutation PostVoteCreate($input: PostVoteCreateInput!) {response: postVoteCreate(input: $input) {node {id...PostVoteButtonFragment}errors {namemessages}}}
% Forms
Input
Input Loading
Input LoadingSuccess
Input LoadingSuccessErrors
Submit ServerSuccessErrors
mutation Server{ node: 'obj' }{ errors: {…} }
mutation Server{ node: 'obj' }{ errors: {field1: 'error1', field2: 'error2' } }
mutation UserSettingsUpdate($input: UserSettingsUpdateInput!) {response: userSettingsUpdate(input: $input) {node {id...MySettingsPageViewer}errors {fieldmessage}}}
mutation={MUTATION}initialValues={{name: settings.name,headline: settings.headline,email: settings.email,websiteUrl: settings.websiteUrl,}}>
Form.Mutation = ({children,className,id,initialValues,mutation,mutationInput,onSubmit,onError,updateCache,validate,}) => {const submit = useSubmit({mutation, mutationInput, updateCache, onSubmit, onError,});return (className={className}id={id}initialValues={initialValues}submit={submit}validate={validate}>{children});};
function useSubmit({mutation,mutationInput,updateCache,onSubmit,onError,}) {const ref = React.useRef({});ref.current.mutation = mutation;ref.current.mutationInput = mutationInput;ref.current.updateCache = updateCache;ref.current.onSubmit = onSubmit;ref.current.onError = onError;return React.useCallback(async (values: any, ..._rest: any) => {const { node, errors } = await executeMutation({mutation: ref.current.mutation,input: normalizeInput(buildInput(values, ref.current.mutationInput)),update: ref.current.updateCache,});if (errors) {ref.current.onError?.(errors);
return React.useCallback(async (values: any, ..._rest: any) => {const { node, errors } = await executeMutation({mutation: ref.current.mutation,input: normalizeInput(buildInput(values, ref.current.mutationInput)),update: ref.current.updateCache,});if (errors) {ref.current.onError?.(errors);return errors;}ref.current.onSubmit?.(node);},[ref],);}function buildInput(input: any,mutationInput: undefined | null | any | ((data: any) => any),) {if (typeof mutationInput === 'function') {return mutationInput(input);
function buildInput(input: any,mutationInput: undefined | null | any | ((data: any) => any),) {if (typeof mutationInput === 'function') {return mutationInput(input);}if (mutationInput) {return { ...mutationInput, ...input };}return input;}function normalizeInput(input: any) {const object = {};for (const key in input) {if (input.hasOwnProperty(key)) {object[key] = typeof input[key] === 'undefined' ? null : input[key];}}return object;}
' utils/graphql
interface IEdge {node: TNode;}export interface IConnectionPartial {edges: IEdge[];}export interface IConnection {edges: IEdge[];pageInfo: {startCursor?: string | null;endCursor: string | null;hasNextPage: boolean;};}export type IArrayOrConnection = T[] | IConnectionPartial;export function length(list: IArrayOrConnection | null) {if (!list) {return 0;}return Array.isArray(list) ? list.length : list.edges!.length;}export function isPresent(list: IArrayOrConnection | null) {return length(list) > 0;
export function isPresent(list: IArrayOrConnection | null) {return length(list) > 0;}export function isEmpty(list: IArrayOrConnection | null) {return length(list) === 0;}export function toArray(list: IArrayOrConnection): T[] {return Array.isArray(list) ? list : list.edges.map((edge) => edge.node);}export function map(list: IArrayOrConnection,fn: (node: T, i: number) => R,) {return Array.isArray(list)? list.map(fn): list.edges!.map(({ node }: any, i) => fn(node, i));}export function first(list: IArrayOrConnection) {return Array.isArray(list) ? list[0] : list?.edges[0]?.node;}export function hasNextPage(connection: IConnection): boolean {return connection.pageInfo.hasNextPage;}
Recap
( What is GraphQL) What is Apollo* Tools on top of Apollo + loadMore + useOnClick + Form.Mutation + utils/graphql
Thanks ,
Thanks ,https://rstankov.com/appearances