relay icon indicating copy to clipboard operation
relay copied to clipboard

Request: useLazyloadQuery hook without suspense

Open alex-statsig opened this issue 1 year ago • 9 comments

I've migrated most of my team's codebase to useLazyloadQuery, using the Transitions API to make smooth transitions without suspending. However, one big pain point still is queries that don't need to block the page to load. For example, typeaheads or autocomplete options. In these cases, it makes more sense to just put a spinner inside the typeahead, a setup that doesn't lend itself to suspense + fallbacks as far as I can tell. Currently we use useQuery from relay-hooks in that case, but it causes confusion around when to use useQuery vs useLazyloadQuery.

The best workaround I have (other than resorting to another library which doesn't fully support react 18 strict mode) is to manually call fetchQuery and/or retain queries myself. That really feels like something the library should just be supporting if it's supporting something like useLazyloadQuery. I'm especially curious what Meta does internally in this case / what the recommendation is.

I tried searching for other Issues about this since I think they must exist, but couldn't find them, so apologies if there's another source of truth for this discussion.

alex-statsig avatar Oct 02 '22 16:10 alex-statsig

I think you can do typeaheads just fine with the existing API.

function Typeahead() {
  const [search, setSearch] = useState("")
  const deferredSearch = useDeferredValue(search)

  return <>
    <input value={search} onChange{e => setSearch(e.target.value)} />
    <Suspense fallback={...}>
      <SearchResults search={deferredSearch} />
    </Suspense>
  </>
}

function SearchResults({ search }) {
  const data = useLazyLoadQuery(myQuery, { search })
  // ...
}

(I typed this on mobile, please forgive any errors)

tobias-tengler avatar Oct 02 '22 19:10 tobias-tengler

What are the odds: I came to post the exact request for the exact same scenario and this is the first issue!

punkpeye avatar Oct 02 '22 23:10 punkpeye

I think you can do typeaheads just fine with the existing API.

function Typeahead() {
  const [search, setSearch] = useState("")
  const deferredSearch = useDeferredValue(search)

  return <>
    <input value={search} onChange{e => setSearch(e.target.value)} />
    <Suspense fallback={...}>
      <SearchResults search={deferredSearch} />
    </Suspense>
  </>
}

function SearchResults({ search }) {
  const data = useLazyLoadQuery(myQuery, { search })
  // ...
}

(I typed this on mobile, please forgive any errors)

While this may work with this model of components, many standardized components just expect an isLoading prop (ex: Material UI autocomplete) to add an adornment (spinner) to an existing UI, rather than replace it with something else.

Additionally suppose there are some already-available default options (recently searched). In that case, it gets even harder to force suspense to work (a single options array must be passed in, and cannot have some of its sources suspend or else the entire array-generating component would have suspended; unless you use children as the array of options, which is very forceful on APIs)

alex-statsig avatar Oct 03 '22 07:10 alex-statsig

I've the same headache with Suspense, some cases are hard to cover like refecthing data. Furthermore, the documentation is not very clear or reassuring about "production ready" of this approach with Relay.

This is a custom hook I've created but I'm not really convinced by my abstraction :

// @flow

import { fetchQuery } from 'relay-runtime'

import type { Variables, FetchPolicy, CacheConfig, Query } from 'relay-runtime'
import { useCallback, useMemo, useState } from 'react'
import { useLazyLoadQuery, useRelayEnvironment } from 'react-relay'

type RefecthingOptions<TVariables> = {|
    counter: number,
    fetchPolicy?: FetchPolicy | typeof undefined,
    variables: TVariables,
|}

type UseRefetchableLazyLoadQueryResult<TVariables, TData> = {|
    data: TData,
    isRefetching: boolean,
    refresh: (overrideVariables: $Shape<TVariables>) => void,
|}

/**
 * This custom hooks provide three properties data / isRefetching / refresh to use useLazyLoadQuery
 * with built-in refresh capabilities.
 * Based on https://relay.dev/docs/guided-tour/refetching/refreshing-queries/
 *
 * documentation of useLazyLoadQuery :
 * Hook used to fetch a GraphQL query during render. This hook can trigger multiple nested or waterfalling round trips if
 * used without caution, and waits until render to start a data fetch (when it can usually start a lot sooner than render),
 * thereby degrading performance. Instead, prefer usePreloadedQuery.
 *
 * @link https://relay.dev/docs/api-reference/use-lazy-load-query/
 *
 * @param {*} gqlQuery GraphQL Query
 * @param {*} variables Variables to send to query
 * @param {*} options Options to specify to hook
 * @returns {UseRefetchableLazyLoadQueryResult} return an object with {data / isRefetching / refresh} inside
 */
export function useRefetchableLazyLoadQuery<TVariables: Variables, TData>(
    gqlQuery: Query<TVariables, TData>,
    variables: TVariables,
    options?: {
        fetchPolicy?: FetchPolicy,
        networkCacheConfig?: CacheConfig,
    }
): UseRefetchableLazyLoadQueryResult<TVariables, TData> {
    const environment = useRelayEnvironment()
    const [fetchingOptions, setFecthingOptions] = useState<RefecthingOptions<TVariables>>({
        counter: 0,
        variables,
    })
    const [isRefetching, setIsRefetching] = useState(false)

    const baseOptions = options ?? {}
    const data = useLazyLoadQuery(gqlQuery, fetchingOptions.variables, {
        ...baseOptions,
        fetchKey: fetchingOptions.counter,
        fetchPolicy: fetchingOptions.fetchPolicy
            ? fetchingOptions.fetchPolicy
            : options?.fetchPolicy,
    })

    const refresh = useCallback(
        (overrideVariables: $Shape<TVariables>) => {
            if (isRefetching) {
                return
            }

            setIsRefetching(true)

            const newVariables = { ...variables, ...overrideVariables }

            // fetchQuery will fetch the query and write
            // the data to the Relay store. This will ensure
            // that when we re-render, the data is already
            // cached and we don't suspend
            fetchQuery(environment, gqlQuery, newVariables).subscribe({
                complete: () => {
                    setIsRefetching(false)

                    // *After* the query has been fetched, we update
                    // our state to re-render with the new fetchKey
                    // and fetchPolicy.
                    // At this point the data for the query should
                    // be cached, so we use the 'store-only'
                    // fetchPolicy to avoid suspending.
                    setFecthingOptions((previousRefecthingOptions) => ({
                        counter: previousRefecthingOptions.counter + 1,
                        fetchPolicy: 'store-only',
                        variables: newVariables,
                    }))
                },
                error: () => {
                    setIsRefetching(false)
                },
            })
        },
        [environment, gqlQuery, isRefetching, variables]
    )

    return useMemo(
        () => ({
            data,
            isRefetching,
            refresh,
        }),
        [data, isRefetching, refresh]
    )
}

michael-haberzettel avatar Oct 04 '22 07:10 michael-haberzettel

I've the same headache with Suspense, some cases are hard to cover like refecthing data. Furthermore, the documentation is not very clear or reassuring about "production ready" of this approach with Relay.

This is a custom hook I've created but I'm not really convinced by my abstraction :

// @flow

import { fetchQuery } from 'relay-runtime'

import type { Variables, FetchPolicy, CacheConfig, Query } from 'relay-runtime'
import { useCallback, useMemo, useState } from 'react'
import { useLazyLoadQuery, useRelayEnvironment } from 'react-relay'

type RefecthingOptions<TVariables> = {|
    counter: number,
    fetchPolicy?: FetchPolicy | typeof undefined,
    variables: TVariables,
|}

type UseRefetchableLazyLoadQueryResult<TVariables, TData> = {|
    data: TData,
    isRefetching: boolean,
    refresh: (overrideVariables: $Shape<TVariables>) => void,
|}

/**
 * This custom hooks provide three properties data / isRefetching / refresh to use useLazyLoadQuery
 * with built-in refresh capabilities.
 * Based on https://relay.dev/docs/guided-tour/refetching/refreshing-queries/
 *
 * documentation of useLazyLoadQuery :
 * Hook used to fetch a GraphQL query during render. This hook can trigger multiple nested or waterfalling round trips if
 * used without caution, and waits until render to start a data fetch (when it can usually start a lot sooner than render),
 * thereby degrading performance. Instead, prefer usePreloadedQuery.
 *
 * @link https://relay.dev/docs/api-reference/use-lazy-load-query/
 *
 * @param {*} gqlQuery GraphQL Query
 * @param {*} variables Variables to send to query
 * @param {*} options Options to specify to hook
 * @returns {UseRefetchableLazyLoadQueryResult} return an object with {data / isRefetching / refresh} inside
 */
export function useRefetchableLazyLoadQuery<TVariables: Variables, TData>(
    gqlQuery: Query<TVariables, TData>,
    variables: TVariables,
    options?: {
        fetchPolicy?: FetchPolicy,
        networkCacheConfig?: CacheConfig,
    }
): UseRefetchableLazyLoadQueryResult<TVariables, TData> {
    const environment = useRelayEnvironment()
    const [fetchingOptions, setFecthingOptions] = useState<RefecthingOptions<TVariables>>({
        counter: 0,
        variables,
    })
    const [isRefetching, setIsRefetching] = useState(false)

    const baseOptions = options ?? {}
    const data = useLazyLoadQuery(gqlQuery, fetchingOptions.variables, {
        ...baseOptions,
        fetchKey: fetchingOptions.counter,
        fetchPolicy: fetchingOptions.fetchPolicy
            ? fetchingOptions.fetchPolicy
            : options?.fetchPolicy,
    })

    const refresh = useCallback(
        (overrideVariables: $Shape<TVariables>) => {
            if (isRefetching) {
                return
            }

            setIsRefetching(true)

            const newVariables = { ...variables, ...overrideVariables }

            // fetchQuery will fetch the query and write
            // the data to the Relay store. This will ensure
            // that when we re-render, the data is already
            // cached and we don't suspend
            fetchQuery(environment, gqlQuery, newVariables).subscribe({
                complete: () => {
                    setIsRefetching(false)

                    // *After* the query has been fetched, we update
                    // our state to re-render with the new fetchKey
                    // and fetchPolicy.
                    // At this point the data for the query should
                    // be cached, so we use the 'store-only'
                    // fetchPolicy to avoid suspending.
                    setFecthingOptions((previousRefecthingOptions) => ({
                        counter: previousRefecthingOptions.counter + 1,
                        fetchPolicy: 'store-only',
                        variables: newVariables,
                    }))
                },
                error: () => {
                    setIsRefetching(false)
                },
            })
        },
        [environment, gqlQuery, isRefetching, variables]
    )

    return useMemo(
        () => ({
            data,
            isRefetching,
            refresh,
        }),
        [data, isRefetching, refresh]
    )
}

Why do you not use useRefetchableFragment? You can also do root field refetching without your server having to implememt the global object identification spec.

tobias-tengler avatar Oct 04 '22 11:10 tobias-tengler

@tobias-tengler : because I want to fetch data and preserve previous informations on screen.

michael-haberzettel avatar Oct 04 '22 13:10 michael-haberzettel

@tobias-tengler : because I want to fetch data and preserve previous informations on screen.

useTransition of React 18 should help you with that. Just wrap the refetch call in a transition and the old data will be retained, while the refetch is happening.

tobias-tengler avatar Oct 04 '22 13:10 tobias-tengler

@tobias-tengler : because I want to fetch data and preserve previous informations on screen.

useTransition of React 18 should help you with that. Just wrap the refetch call in a transition and the old data will be retained, while the refetch is happening.

I can't migrate to React 18 right now. I've legacy code and it's not easy to migrate to concurrent mode :cry:

michael-haberzettel avatar Oct 04 '22 13:10 michael-haberzettel

@tobias-tengler I think the conversation diverged to another discussion here, but I'm curious still if theres a recommended way to fetch data without suspense. Atm I still rely on "relay-hooks" package for their non-suspense-based "useQuery" hook, which can be annoying to maintain with "react-relay" at the same time (both export useFragment with slightly different types, etc.). I could roll my own via "fetchQuery", but retaining would be a mess.

Is the only reason its unsupported just to avoid supporting two different patterns in this core package, even though one pattern (Suspense) has clear limitations at times? I love the Suspense approach most of the time, but need the option to avoid it

alex-statsig avatar Mar 10 '24 01:03 alex-statsig