Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Define Query & Mutation Usage (useQuery, cache invalidation, etc.) #89

Closed
2 of 3 tasks
flybayer opened this issue Apr 8, 2020 · 13 comments
Closed
2 of 3 tasks

Comments

@flybayer
Copy link
Member

flybayer commented Apr 8, 2020

We need to define exactly how queries and mutations are used in Blitz app code.

  • Decide whether to go all in on Suspense (yes)
  • SSR usage (must go through middleware)
  • Intelligent cache invalidation

Requirements

  • Using a query/mutation in server code (like getStaticProps) should directly use the query/mutation. It should not make an RPC call to another lambda
  • Using a query/mutation in client code should use automatically be transformed to make an RPC call to a Lambda (server code is not bundled with client code)
  • Support all the nice features of react-query/swr for auto caching, refetching, stale-while-revalidate semantics, revalidate on window focus, etc.
// routes/product/[id]/edit.tsx
import {useQuery, Router} from 'blitz'
import getProduct from '/app/products/queries/getProduct'
import updateProduct from '/app/products/mutations/updateProduct'
import {Formik} from 'formik'

export default function(props) {
  const [product] = useQuery(getProduct, {where: {id: props.query.id}})

  return (
    <div>
      <h1>{product.name}</h1>
      <Formik
        initialValues={product}
        onSubmit={async values => {
          try {
            const {id} = await updateProduct(values)
            Router.push('/products/[id]', `/products/${id}`)
          } catch (error) {
            alert('Error saving product')
          }
        }}>
        {({handleSubmit}) => <form onSubmit={handleSubmit}></form>}
      </Formik>
    </div>
  )
}

Queries

In a React Component

Blitz provides a useQuery hook. The first argument is a query function. The second argument is the input to the query function.

  • We use react-query under the hood for implementing this hook.
import getProduct from '/app/products/queries/getProduct'

export default function(props: {query: {id: number}}) {
  const [product] = useQuery(getProduct, {where: {id: props.query.id}})
  
  return <div>{product.name}</div>
}

On the Server

In server-side code, a query function can be called directly without useQuery

import getProduct from '/app/products/queries/getProduct'

export const getStaticProps = async context => {
  const product = await getProduct({where: {id: context.params?.id}})
  return {props: {product}}
}

export default function({product}) {
  return <div>{product.name}</div>
}

Cache Key

  • Blitz automatically generates the query cache key
  • Most Blitz users will never need to know about the cache key.
  • For the above query, the cache key would be something like ["/api/products/queries/getProduct", {where: {id: props.query.id}]

React Suspense & Concurrent Mode

Blitz apps have concurrent mode enabled by default, so suspense for data fetching is also our default.

SSR Suspense

Our current plan is to not support SSR for useQuery. We're hoping the official new concurrent mode SSR renderer is out within a few months.

  • We could use a package like react-async-ssr until the official suspense SSR renderer is released
  • Or we can do fancy stuff and preload all the query data into the cache before calling renderToString
  • Or we can just not support SSR for useQuery

Dependent Queries

The second query will not execute until the second function argument can be called without throwing.

const [user] = useQuery(getUser, {where: {id: props.query.id}})
const [products] = useQuery(getProducts, () => ({where: {userId: user.id}}))

Prefetching (not implemented: blitz-js/legacy-framework#821)

  • All queries are automatically cached, so manually calling a query function will cache it's data

Both of the following will cache the getProduct query.

const product = await getProduct({where: {id: props.id}})
<button onMouseEnter={() => getProduct({where: {id: productId}})}>
  View Product
</button>

Pagination

Use the usePaginatedQuery hook

import {Suspense, useState} from 'react'
import {Link, BlitzPage, usePaginatedQuery} from 'blitz'
import getProducts from 'app/products/queries/getProducts'

const ITEMS_PER_PAGE = 3

const Products = () => {
  const [page, setPage] = useState(0)
  const [products] = usePaginatedQuery(getProducts, {
    skip: ITEMS_PER_PAGE * page,
    first: ITEMS_PER_PAGE,
  })

  return (
    <div>
      {products.map((product) => (
        <p key={product.id}>
          <Link href="/products/[handle]" as={`/products/${product.handle}`}>
            <a>{product.name}</a>
          </Link>
        </p>
      ))}
      <button disabled={page === 0} onClick={() => setPage(page - 1)}>
        Previous
      </button>
      <button disabled={products.length !== ITEMS_PER_PAGE} onClick={() => setPage(page + 1)}>
        Next
      </button>
    </div>
  )
}

const Page: BlitzPage = function () {
  return (
    <div>
      <h1>Products - Paginated</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <Products />
      </Suspense>
    </div>
  )
}
export default Page

Infinite Loading (not implemented: blitz-js/legacy-framework#819)

import { useInfiniteQuery } from 'blitz'
import getProducts from '/app/products/queries/getProducts'

function Products(props) {
  const [
    groupedProducts,
    {
      isFetching,
      isFetchingMore,
      fetchMore,
      canFetchMore,
    }
  ] = useInfiniteQuery(
    getProducts, 
    (page = {first: 100, skip: 0}) => ({where: {storeId: props.storeId}, ...page}),
    {
      getFetchMore: (lastGroup, allGroups) => lastGroup.nextPage,
    }
  )

  return (
    <>
      {groupedProducts.map((group, i) => (
        <React.Fragment key={i}>
          {group.products.map(product => (
            <p key={product.id}>{product.name}</p>
          ))}
        </React.Fragment>
      ))}

      <div>
        <button
          onClick={() => fetchMore()}
          disabled={!canFetchMore || isFetchingMore}
        >
          {isFetchingMore
            ? 'Loading more...'
            : canFetchMore
            ? 'Load More'
            : 'Nothing more to load'}
        </button>
      </div>

      <div>{isFetching && !isFetchingMore ? 'Fetching...' : null}</div>
    </>
  )
}

SSR

Queries can be used for SSR with the ssrQuery function. ssrQuery will run the appropriate middleware.

import {ssrQuery} from 'blitz'
import getProduct from '/app/products/queries/getProduct'

export const getServerSideProps = async ({params, req, res}) => {
  const product = await ssrQuery(getProduct, {where: {id: params.id}}, {req, res}))
  return {props: {product}}
}

export default function({product}) {
  return <div>{product.name}</div>
}

Window-Focus Refetching

If a user leaves your application and returns to stale data, you usually want to trigger an update in the background to update any stale queries. Blitz useQuery will do this automatically for you, but there will be an option to disable it.

Optimistic Updates

I think optimistic updates should very rarely be used. The UX for end-users is very tricky to get right with optimistic updates.

Therefore, I think optimistic updates should not have first-class support in Blitz. If users need this, they can use directly use react-query or some other method.

Advanced Features & Config

We accept any react-query config item as the third argument to useQuery.

These include refetchInterval, initialData, retry, retryDelay, cacheTime, etc.

Mutations

Mutations are called directly. There is no useMutation hook like react-query has.

import updateProduct from '/app/products/mutations/updateProduct'

try {
  const product = await updateProduct({name: 'something'})
} catch (error) {
  alert('Error saving product')
}

Cache Invalidation (not implemented)

  1. On Route Transition
  2. refetch
  3. mutate

On Route Transition

  • Router.push and Router.replace accepts a refetchQueries option, that when true, will cause all queries on the destination page to be invalidated.
export default function(props: {query: {id: number}}) {
  const [product, {mutate}] = useQuery(getProduct, {where: {id: props.query.id}})

  return (
    <Formik
      initialValues={product}
      onSubmit={async values => {
        try {
          const {id} = await updateProduct(values)
          Router.push('/products/[id]', `/products/${id}`, {refetchQueries: true})
        } catch (error) {
          alert('Error saving product')
        }
      }}>
      {/* ... */}
    </Formik>
  )
}

refetch

  • useQuery returns a refetch function you can use to trigger a query reload
export default function(props: {query: {id: number}}) {
  const [product, {refetch}] = useQuery(getProduct, {where: {id: props.query.id}})

  return (
    <Formik
      initialValues={product}
      onSubmit={async values => {
        try {
          const product = await updateProduct(values)
          refetch()
        } catch (error) {
          alert('Error saving product')
        }
      }}>
      {/* ... */}
    </Formik>
  )
}

mutate

  • useQuery returns a mutate function you can use to manually update the cache
  • Calling mutate will automatically trigger a refetch for the initial query to ensure it has the correct data
  • mutate will be fully typed for Typescript usage
export default function(props: {query: {id: number}}) {
  const [product, {mutate}] = useQuery(getProduct, {where: {id: props.query.id}})

  return (
    <Formik
      initialValues={product}
      onSubmit={async values => {
        try {
          const product = await updateProduct(values)
          mutate(product)
        } catch (error) {
          alert('Error saving product')
        }
      }}>
      {/* ... */}
    </Formik>
  )
}

Related Blitz Issues

@cr101
Copy link

cr101 commented Apr 9, 2020

In server-side code, a query function can be called directly without useQuery

Does that mean there will be no option to cache queries on the server-side? We need data caching and SSR Suspense for our web app.

React Suspense with react-cache is also an option in the future - https://hackernoon.com/magic-of-react-suspense-with-concurrent-react-and-react-lazy-api-e32dc5f30ed1

@flybayer
Copy link
Member Author

flybayer commented Apr 9, 2020

@cr101 good question.

What type of caching do you need on the server? And do you need this for serverful or serverless deployment?

Blitz will definitely support SSR suspense — as soon as Next.js does, which I expect to be at or soon after the production release.

@ryardley
Copy link
Collaborator

ryardley commented Apr 9, 2020

Does that mean there will be no option to cache queries on the server-side?

I would imagine that might be a good candidate for an optional middleware?

@ryardley
Copy link
Collaborator

ryardley commented Apr 9, 2020

On the surface react-async-ssr looks really nice! I am not sure without working through the details as to how much easier that would be to hook up with Next.js as opposed to doing a standard SSR query preload as whilst I would have to look at it to be sure, I am not sure we can easily override the actual renderToString routine to replace it with the async version. So far what I have managed to do was run queries and manually hydrate a custom global cache.

@ryardley
Copy link
Collaborator

ryardley commented Apr 9, 2020

As far as this API is concerned this client-side cache invalidation stuff is getting complex. Could we possibly be straying away from the core premise of blitz that you should never need to worry about data fetching? I am wondering if we could merge queries into a resource if we would then get more automatic mileage in terms of cache invalidation after mutations as you could configure this is a resource and it's queries, mutations and cache could be linked? Thoughts? Perhaps the above API could be for advanced usage?

const productResource = createResource({
  get: getProduct,
  update: updateProduct,
  create: createProduct,
  delete: deleteProduct
});


export default function(props: {query: {id: number}}) {
  const [product] = useResource(productResource.get({where: {id: props.query.id}}))
  return (
    <Formik
      initialValues={product}
      onSubmit={async values => {
        try {
          const product = await productResource.update(values)
        } catch (error) {
          alert('Error saving product')
        }
      }}>
      {/* ... */}
    </Formik>
  )
}

I know this has echoes of the serverside rails controllers we had before.

@flybayer
Copy link
Member Author

flybayer commented Apr 9, 2020

Invalidating cache on route transitions is quite simple, yeah? And instead of requiring an opt-in, we could automatically invalidate cache on route transitions and then have an option to opt out.

refetch and mutate are for advanced cases.

Unless we enforce 100% SSR, then there's no way to get around client side cache. So the best we can do is make the common case extremely simple. I think a route transition will be the most common case.

@ryardley
Copy link
Collaborator

ryardley commented Apr 10, 2020

SSR doesn't help with client side cache unless you mean full server rendered pages on every interaction which is not an option.

This could be a leaky abstraction if application developers don't change page routes after mutations. Think edit in place fields. Spreadsheets. Drag and drop interfaces. Not every app has a save button. The common case is crud interactions not route transitions. I think we need to link interactions to inform the way the cache works by default. Could be configuration doesn't matter how. Could even be convention inferred by cache key? ie. the path of the query method or the name of the interaction.

@flybayer
Copy link
Member Author

I'll think more about the cache stuff and report back later.

I realized something I overlooked: applying middleware in getServerSideProps. Middleware can't be used in getStaticProps because that is at build time without a request, but gSSP has req & res.

Here's what I'm thinking. Thoughts?

import {ssrQuery} from 'blitz'
import getProduct from '/app/products/queries/getProduct'

export const getServerSideProps = async ({params, req, res}) => {
  const product = await ssrQuery(getProduct, {where: {id: params.id}}, {req, res}))
  return {props: {product}}
}

export default function({product}) {
  return <div>{product.name}</div>
}

Alternately, we could use a babel transform to add the req and res objects to the ssrQuery call so the user doesn't have to. But I don't really think that's a good idea.

@sijad
Copy link

sijad commented Apr 13, 2020

as blitz does not support graphql it would become out of hand to define and maintaining queries and their types in large project.
would be nice if blitz could auto generate queries using prisma somehow, something like https://gqless.dev

@flybayer
Copy link
Member Author

The blitz generate cli command will generate queries and mutations for you. From then on you only need to customize if needed. If this still doesn't provide good enough DX, then we can explore more things.

@cr101
Copy link

cr101 commented Apr 13, 2020

Will it be possible to query the database using raw queries via prisma.raw()?

@flybayer
Copy link
Member Author

@cr101, yes you bet! You have full access to prisma. The only thing we are planning to do for now is wrap the prisma CLI like blitz db migrate. But in your code, you import prisma directly.

@flybayer
Copy link
Member Author

Closing as complete! I've added the documentation form here to the new docs website that will go live within a day or so.

I also opened #586 to discuss automatic cache invalidation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants