From 2cb91731d84da5b6972e80223e9e10e2404b6bba Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 8 Dec 2020 10:24:53 +0100 Subject: [PATCH 1/5] (docs): add typescript example for optimistic updates --- .../optimistic-updates-typescript/.gitignore | 26 ++++ .../optimistic-updates-typescript/README.md | 6 + .../next-env.d.ts | 2 + .../next.config.js | 48 +++++++ .../package.json | 21 +++ .../pages/api/data.js | 27 ++++ .../pages/index.tsx | 120 ++++++++++++++++++ .../tsconfig.json | 26 ++++ 8 files changed, 276 insertions(+) create mode 100644 examples/optimistic-updates-typescript/.gitignore create mode 100644 examples/optimistic-updates-typescript/README.md create mode 100644 examples/optimistic-updates-typescript/next-env.d.ts create mode 100644 examples/optimistic-updates-typescript/next.config.js create mode 100755 examples/optimistic-updates-typescript/package.json create mode 100755 examples/optimistic-updates-typescript/pages/api/data.js create mode 100755 examples/optimistic-updates-typescript/pages/index.tsx create mode 100644 examples/optimistic-updates-typescript/tsconfig.json diff --git a/examples/optimistic-updates-typescript/.gitignore b/examples/optimistic-updates-typescript/.gitignore new file mode 100644 index 0000000000..613b2de638 --- /dev/null +++ b/examples/optimistic-updates-typescript/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/optimistic-updates-typescript/README.md b/examples/optimistic-updates-typescript/README.md new file mode 100644 index 0000000000..3ac3f1a9b4 --- /dev/null +++ b/examples/optimistic-updates-typescript/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run dev` or `yarn dev` diff --git a/examples/optimistic-updates-typescript/next-env.d.ts b/examples/optimistic-updates-typescript/next-env.d.ts new file mode 100644 index 0000000000..7b7aa2c772 --- /dev/null +++ b/examples/optimistic-updates-typescript/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/optimistic-updates-typescript/next.config.js b/examples/optimistic-updates-typescript/next.config.js new file mode 100644 index 0000000000..d38331b88e --- /dev/null +++ b/examples/optimistic-updates-typescript/next.config.js @@ -0,0 +1,48 @@ +const Module = require('module') +const path = require('path') +const resolveFrom = require('resolve-from') + +const node_modules = path.resolve(__dirname, 'node_modules') + +const originalRequire = Module.prototype.require + +// The following ensures that there is always only a single (and same) +// copy of React in an app at any given moment. +Module.prototype.require = function (modulePath) { + // Only redirect resolutions to non-relative and non-absolute modules + if ( + ['/react/', '/react-dom/', '/react-query/'].some(d => { + try { + return require.resolve(modulePath).includes(d) + } catch (err) { + return false + } + }) + ) { + try { + modulePath = resolveFrom(node_modules, modulePath) + } catch (err) { + // + } + } + + return originalRequire.call(this, modulePath) +} + +module.exports = { + webpack: config => { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve.alias, + react$: resolveFrom(path.resolve('node_modules'), 'react'), + 'react-query$': resolveFrom( + path.resolve('node_modules'), + 'react-query' + ), + 'react-dom$': resolveFrom(path.resolve('node_modules'), 'react-dom'), + }, + } + return config + }, +} diff --git a/examples/optimistic-updates-typescript/package.json b/examples/optimistic-updates-typescript/package.json new file mode 100755 index 0000000000..7f678a18c2 --- /dev/null +++ b/examples/optimistic-updates-typescript/package.json @@ -0,0 +1,21 @@ +{ + "name": "basic", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "axios": "^0.19.2", + "isomorphic-unfetch": "3.0.0", + "next": "9.2.2", + "react": "^17.0.1", + "react-dom": "^17.0.1", + "react-query": "^3.2.0-beta.32", + "react-query-devtools": "^3.0.0-beta.1", + "typescript": "^4.1.2" + }, + "scripts": { + "dev": "next", + "start": "next start", + "build": "next build" + } +} diff --git a/examples/optimistic-updates-typescript/pages/api/data.js b/examples/optimistic-updates-typescript/pages/api/data.js new file mode 100755 index 0000000000..10cab56281 --- /dev/null +++ b/examples/optimistic-updates-typescript/pages/api/data.js @@ -0,0 +1,27 @@ +const items = [] + +export default async (req, res) => { + await new Promise(r => setTimeout(r, 1000)) + + if (req.method === 'POST') { + const { text } = req.body + + // sometimes it will fail, this will cause a regression on the UI + + if (Math.random() > 0.7) { + res.status(500) + res.json({ message: 'Could not add item!' }) + return + } + + const newTodo = { id: Math.random().toString(), text: text.toUpperCase() } + items.push(newTodo) + res.json(newTodo) + return + } else { + res.json({ + ts: Date.now(), + items, + }) + } +} diff --git a/examples/optimistic-updates-typescript/pages/index.tsx b/examples/optimistic-updates-typescript/pages/index.tsx new file mode 100755 index 0000000000..5a4d21ae90 --- /dev/null +++ b/examples/optimistic-updates-typescript/pages/index.tsx @@ -0,0 +1,120 @@ +import * as React from 'react' +import axios from 'axios' + +import { + useQuery, + useQueryClient, + useMutation, + QueryClient, + QueryClientProvider, +} from 'react-query' +import { ReactQueryDevtools } from 'react-query-devtools' + +const client = new QueryClient() + +export default function App() { + return ( + + + + ) +} + +type Todo = { + id: string + text: string +} + +type TodoData = { + items: readonly Todo[] + ts: number +} + +async function fetchTodos(): Promise { + const res = await axios.get('/api/data') + return res.data +} + +function Example() { + const queryClient = useQueryClient() + const [text, setText] = React.useState('') + const { isFetching, ...queryInfo } = useQuery('todos', fetchTodos) + + const addTodoMutation = useMutation( + (newTodo: string) => axios.post('/api/data', { text: newTodo }), + { + // Optimistically update the cache value on mutate, but store + // the old value and return it so that it's accessible in case of + // an error + onMutate: async newTodo => { + setText('') + await queryClient.cancelQueries('todos') + + const previousValue = queryClient.getQueryData('todos') + + if (previousValue) { + queryClient.setQueryData('todos', { + ...previousValue, + items: [ + ...previousValue.items, + { id: Math.random().toString(), text: newTodo }, + ], + }) + } + + return previousValue + }, + // On failure, roll back to the previous value + onError: (err, variables, previousValue) => { + queryClient.setQueryData('todos', previousValue) + }, + // // After success or failure, refetch the todos query + onSettled: () => { + queryClient.invalidateQueries('todos') + }, + } + ) + + return ( +
+

+ In this example, new items can be created using a mutation. The new item + will be optimistically added to the list in hopes that the server + accepts the item. If it does, the list is refetched with the true items + from the list. Every now and then, the mutation may fail though. When + that happens, the previous list of items is restored and the list is + again refetched from the server. +

+
{ + e.preventDefault() + addTodoMutation.mutate(text) + }} + > + setText(event.target.value)} + value={text} + /> + +
+
+ {queryInfo.isSuccess && ( + <> +
+ Updated At: {new Date(queryInfo.data.ts).toLocaleTimeString()} +
+
    + {queryInfo.data.items.map(todo => ( +
  • {todo.text}
  • + ))} +
+ {isFetching &&
Updating in background...
} + + )} + {queryInfo.isLoading && 'Loading'} + {queryInfo.error instanceof Error && queryInfo.error.message} + +
+ ) +} diff --git a/examples/optimistic-updates-typescript/tsconfig.json b/examples/optimistic-updates-typescript/tsconfig.json new file mode 100644 index 0000000000..1087618126 --- /dev/null +++ b/examples/optimistic-updates-typescript/tsconfig.json @@ -0,0 +1,26 @@ +{ + "include": [ + "./pages/**/*" + ], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "lib": [ + "dom", + "es2015" + ], + "jsx": "preserve", + "target": "es5", + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true + }, + "exclude": [ + "node_modules" + ] +} From 68d2f819259fe2b8b353d6ffbfde26990dc0ee88 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 8 Dec 2020 11:02:11 +0100 Subject: [PATCH 2/5] (docs): add a Counter to show off the "select" option in TypeScript --- .../pages/index.tsx | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/examples/optimistic-updates-typescript/pages/index.tsx b/examples/optimistic-updates-typescript/pages/index.tsx index 5a4d21ae90..962c4d67db 100755 --- a/examples/optimistic-updates-typescript/pages/index.tsx +++ b/examples/optimistic-updates-typescript/pages/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import axios from 'axios' +import axios, { AxiosError } from 'axios' import { useQuery, @@ -7,6 +7,7 @@ import { useMutation, QueryClient, QueryClientProvider, + UseQueryOptions, } from 'react-query' import { ReactQueryDevtools } from 'react-query-devtools' @@ -16,17 +17,17 @@ export default function App() { return ( + + ) } -type Todo = { - id: string - text: string -} - type TodoData = { - items: readonly Todo[] + items: readonly { + id: string + text: string + }[] ts: number } @@ -35,10 +36,29 @@ async function fetchTodos(): Promise { return res.data } +function useTodos( + options?: UseQueryOptions +) { + return useQuery('todos', fetchTodos, options) +} + +function TodoCounter() { + const counterQuery = useTodos({ + select: data => data.items.length, + notifyOnChangeProps: ['data'], + }) + + React.useEffect(() => { + console.log('rendering counter') + }) + + return
TodoCounter: {counterQuery.data ?? 0}
+} + function Example() { const queryClient = useQueryClient() const [text, setText] = React.useState('') - const { isFetching, ...queryInfo } = useQuery('todos', fetchTodos) + const { isFetching, ...queryInfo } = useTodos() const addTodoMutation = useMutation( (newTodo: string) => axios.post('/api/data', { text: newTodo }), @@ -68,7 +88,7 @@ function Example() { onError: (err, variables, previousValue) => { queryClient.setQueryData('todos', previousValue) }, - // // After success or failure, refetch the todos query + // After success or failure, refetch the todos query onSettled: () => { queryClient.invalidateQueries('todos') }, @@ -102,6 +122,7 @@ function Example() { {queryInfo.isSuccess && ( <>
+ {/* The type of queryInfo.data will be narrowed because we check for isSuccess first */} Updated At: {new Date(queryInfo.data.ts).toLocaleTimeString()}
    @@ -113,8 +134,7 @@ function Example() { )} {queryInfo.isLoading && 'Loading'} - {queryInfo.error instanceof Error && queryInfo.error.message} - + {queryInfo.error?.message} ) } From d75ce8b3dd9cfb83bc0542ca2414795636ce6b1b Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 8 Dec 2020 11:06:10 +0100 Subject: [PATCH 3/5] (docs): simplify TypeScript example There is no need to add generics to userQuery now - it will be inferred correctly by the options --- examples/optimistic-updates-typescript/pages/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/optimistic-updates-typescript/pages/index.tsx b/examples/optimistic-updates-typescript/pages/index.tsx index 962c4d67db..9550590ef3 100755 --- a/examples/optimistic-updates-typescript/pages/index.tsx +++ b/examples/optimistic-updates-typescript/pages/index.tsx @@ -39,7 +39,7 @@ async function fetchTodos(): Promise { function useTodos( options?: UseQueryOptions ) { - return useQuery('todos', fetchTodos, options) + return useQuery('todos', fetchTodos, options) } function TodoCounter() { From 855b202f6ebc8281dcac6307000a16a487378ddb Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Dec 2020 20:41:01 +0100 Subject: [PATCH 4/5] return context from onMutate and define types on the `onMutate` function --- .../pages/index.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/examples/optimistic-updates-typescript/pages/index.tsx b/examples/optimistic-updates-typescript/pages/index.tsx index 9550590ef3..0b8569d1b0 100755 --- a/examples/optimistic-updates-typescript/pages/index.tsx +++ b/examples/optimistic-updates-typescript/pages/index.tsx @@ -23,7 +23,7 @@ export default function App() { ) } -type TodoData = { +type Todos = { items: readonly { id: string text: string @@ -31,18 +31,20 @@ type TodoData = { ts: number } -async function fetchTodos(): Promise { +async function fetchTodos(): Promise { const res = await axios.get('/api/data') return res.data } -function useTodos( - options?: UseQueryOptions +function useTodos( + options?: UseQueryOptions ) { return useQuery('todos', fetchTodos, options) } function TodoCounter() { + // subscribe only to changes in the 'data' prop, which will be the + // amount of todos because of the select function const counterQuery = useTodos({ select: data => data.items.length, notifyOnChangeProps: ['data'], @@ -61,34 +63,37 @@ function Example() { const { isFetching, ...queryInfo } = useTodos() const addTodoMutation = useMutation( - (newTodo: string) => axios.post('/api/data', { text: newTodo }), + newTodo => axios.post('/api/data', { text: newTodo }), { - // Optimistically update the cache value on mutate, but store - // the old value and return it so that it's accessible in case of - // an error - onMutate: async newTodo => { + // When mutate is called: + onMutate: async (newTodo: string) => { setText('') + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) await queryClient.cancelQueries('todos') - const previousValue = queryClient.getQueryData('todos') + // Snapshot the previous value + const previousTodos = queryClient.getQueryData('todos') - if (previousValue) { - queryClient.setQueryData('todos', { - ...previousValue, + // Optimistically update to the new value + if (previousTodos) { + queryClient.setQueryData('todos', { + ...previousTodos, items: [ - ...previousValue.items, + ...previousTodos.items, { id: Math.random().toString(), text: newTodo }, ], }) } - return previousValue + return { previousTodos } }, - // On failure, roll back to the previous value - onError: (err, variables, previousValue) => { - queryClient.setQueryData('todos', previousValue) + // If the mutation fails, use the context returned from onMutate to roll back + onError: (err, variables, context) => { + if (context?.previousTodos) { + queryClient.setQueryData('todos', context.previousTodos) + } }, - // After success or failure, refetch the todos query + // Always refetch after error or success: onSettled: () => { queryClient.invalidateQueries('todos') }, From 8564201a14b18ed6f73232fc1ffaea4d79800416 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Dec 2020 20:41:23 +0100 Subject: [PATCH 5/5] add missing select option to useQuery api reference --- docs/src/pages/reference/useQuery.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index 444679aee0..2a1956dc85 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -39,6 +39,7 @@ const { refetchOnWindowFocus, retry, retryDelay, + select staleTime, structuralSharing, suspense,