Repository: tannerlinsley/react-query
Pull request #883 (2020-08-18 20:20:29)

feat: add structural sharing of data between query results

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: *In Progress*  
✅ Preview: *In Progress*


tannerlinsley: Ooooooooo... This is awesome. What else can you tell me about this?
boschni: This will make sure that if some query is returning a list of todos, and at some point a todo in this list changes, the references to the other todos will remain the same. This allows users to easily check if some part of the data has changed or not (which can be used to prevent unnecessary renders or effects from triggering).

Together with a `notify` option on queries to indicate when some hook should trigger a re-render, it would also be possible to create a hook similar to the `useSelector` hook from Redux. This could then get some specific data from a query and only re-render when the selector result changes:
```
const username = useQuerySelector('user', getUser, getUsername)
```
tannerlinsley: Well shoot dang, that sounds like something I've needed for a while but never tried to build yet.
tannerlinsley: Do you feel this is ready to merge?

boschni: It passes all tests and did some integration testing so this one should be ready to go 👍 

PS: do you also have some time to take a look at the other PRs?

Patch:
diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md
index d2c3cf0aa..911c3f338 100644
--- a/docs/src/pages/docs/comparison.md
+++ b/docs/src/pages/docs/comparison.md
@@ -18,6 +18,7 @@ Feature/Capability Key:
 | Supported Query Signatures                   | Promise                                | Promise                                        | GraphQL Query                                                             |
 | Supported Query Keys                         | JSON                                   | JSON                                           | GraphQL Query                                                             |
 | Query Key Change Detection                   | Deep Compare (Serialization)           | Referential Equality (===)                     | Deep Compare (Serialization)                                              |
+| Query Data Memoization Level                 | Query + Structural Sharing             | Query                                          | Query + Entity + Structural Sharing                                       |
 | Bundle Size                                  | [![][bp-react-query]][bpl-react-query] | [![][bp-swr]][bpl-swr]                         | [![][bp-apollo]][bpl-apollo]                                              |
 | Queries                                      | ✅                                     | ✅                                             | ✅                                                                        |
 | Caching                                      | ✅                                     | ✅                                             | ✅                                                                        |
diff --git a/docs/src/pages/docs/overview.md b/docs/src/pages/docs/overview.md
index 09fb3bd43..7318aa849 100644
--- a/docs/src/pages/docs/overview.md
+++ b/docs/src/pages/docs/overview.md
@@ -25,6 +25,7 @@ Once you grasp the nature of server state in your application, **even more chall
 - Reflecting updates to data as quickly as possible
 - Performance optimizations like pagination and lazy loading data
 - Managing memory and garbage collection of server state
+- Memoizing query results with structural sharing
 
 If you're not overwhelmed by that list, then that must mean that you've probably solved all of your server state problems already and deserve an award. However, if you are like a vast majority of people, you either have yet to tackle all or most of these challenges and we're only scratching the surface!
 
diff --git a/src/core/config.ts b/src/core/config.ts
index cb37fd94d..d2807a667 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -1,4 +1,4 @@
-import { stableStringify, identity, deepEqual } from './utils'
+import { stableStringify, identity } from './utils'
 import {
   ArrayQueryKey,
   QueryKey,
@@ -44,7 +44,6 @@ export const DEFAULT_CONFIG: ReactQueryConfig = {
     refetchInterval: false,
     queryFnParamsFilter: identity,
     refetchOnMount: true,
-    isDataEqual: deepEqual,
     useErrorBoundary: false,
   },
   mutations: {
diff --git a/src/core/query.ts b/src/core/query.ts
index 70d7f38c7..fbb31e94b 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -6,8 +6,8 @@ import {
   noop,
   Console,
   getStatusProps,
-  shallowEqual,
   Updater,
+  replaceEqualDeep,
 } from './utils'
 import { QueryInstance, OnStateUpdateFunction } from './queryInstance'
 import {
@@ -81,7 +81,7 @@ interface FetchAction {
 
 interface SuccessAction<TResult> {
   type: ActionType.Success
-  updater: Updater<TResult | undefined, TResult>
+  data: TResult | undefined
   isStale: boolean
 }
 
@@ -157,14 +157,9 @@ export class Query<TResult, TError> {
   }
 
   private dispatch(action: Action<TResult, TError>): void {
-    const newState = queryReducer(this.state, action)
-
-    // Only update state if something has changed
-    if (!shallowEqual(this.state, newState)) {
-      this.state = newState
-      this.instances.forEach(d => d.onStateUpdate(newState, action))
-      this.notifyGlobalListeners(this)
-    }
+    this.state = queryReducer(this.state, action)
+    this.instances.forEach(d => d.onStateUpdate(this.state, action))
+    this.notifyGlobalListeners(this)
   }
 
   scheduleStaleTimeout(): void {
@@ -283,11 +278,25 @@ export class Query<TResult, TError> {
   }
 
   setData(updater: Updater<TResult | undefined, TResult>): void {
+    const prevData = this.state.data
+
+    // Get the new data
+    let data: TResult | undefined = functionalUpdate(updater, prevData)
+
+    // Structurally share data between prev and new data
+    data = replaceEqualDeep(prevData, data)
+
+    // Use prev data if an isDataEqual function is defined and returns `true`
+    if (this.config.isDataEqual?.(prevData, data)) {
+      data = prevData
+    }
+
     const isStale = this.config.staleTime === 0
+
     // Set data and mark it as cached
     this.dispatch({
       type: ActionType.Success,
-      updater,
+      data,
       isStale,
     })
 
@@ -502,13 +511,15 @@ export class Query<TResult, TError> {
       this.cancelled = null
 
       try {
-        // Set up the query refreshing state
-        this.dispatch({ type: ActionType.Fetch })
+        // Set to fetching state if not already in it
+        if (!this.state.isFetching) {
+          this.dispatch({ type: ActionType.Fetch })
+        }
 
         // Try to get the data
         const data = await this.tryFetchData(queryFn!, this.queryKey)
 
-        this.setData(old => (this.config.isDataEqual!(old, data) ? old! : data))
+        this.setData(data)
 
         delete this.promise
 
@@ -610,7 +621,7 @@ export function queryReducer<TResult, TError>(
       return {
         ...state,
         ...getStatusProps(QueryStatus.Success),
-        data: functionalUpdate(action.updater, state.data),
+        data: action.data,
         error: null,
         isStale: action.isStale,
         isFetched: true,
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 7a878f097..751029fcc 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -69,10 +69,6 @@ export function stableStringify(value: any): string {
   return JSON.stringify(value, stableStringifyReplacer)
 }
 
-export function isObject(a: unknown): boolean {
-  return a && typeof a === 'object' && !Array.isArray(a)
-}
-
 export function deepIncludes(a: any, b: any): boolean {
   if (a === b) {
     return true
@@ -135,57 +131,77 @@ export function getQueryArgs<TResult, TError, TOptions = undefined>(
 }
 
 export function deepEqual(a: any, b: any): boolean {
-  return equal(a, b, true)
-}
-
-export function shallowEqual(a: any, b: any): boolean {
-  return equal(a, b, false)
-}
-
-// This deep-equal is directly based on https://github.com/epoberezkin/fast-deep-equal.
-// The parts for comparing any non-JSON-supported values has been removed
-function equal(a: any, b: any, deep: boolean, depth = 0): boolean {
-  if (a === b) return true
-
-  if (
-    (deep || !depth) &&
-    a &&
-    b &&
-    typeof a == 'object' &&
-    typeof b == 'object'
-  ) {
-    let length, i
-    if (Array.isArray(a)) {
-      length = a.length
-      // eslint-disable-next-line eqeqeq
-      if (length != b.length) return false
-      for (i = length; i-- !== 0; )
-        if (!equal(a[i], b[i], deep, depth + 1)) return false
-      return true
-    }
+  return replaceEqualDeep(a, b) === a
+}
 
-    if (a.valueOf !== Object.prototype.valueOf)
-      return a.valueOf() === b.valueOf()
+/**
+ * This function returns `a` if `b` is deeply equal.
+ * If not, it will replace any deeply equal children of `b` with those of `a`.
+ * This can be used for structural sharing between JSON values for example.
+ */
+export function replaceEqualDeep<T>(a: unknown, b: T): T
+export function replaceEqualDeep(a: any, b: any): any {
+  if (a === b) {
+    return a
+  }
 
-    const keys = Object.keys(a)
-    length = keys.length
-    if (length !== Object.keys(b).length) return false
+  const array = Array.isArray(a) && Array.isArray(b)
 
-    for (i = length; i-- !== 0; )
-      if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false
+  if (array || (isPlainObject(a) && isPlainObject(b))) {
+    const aSize = array ? a.length : Object.keys(a).length
+    const bItems = array ? b : Object.keys(b)
+    const bSize = bItems.length
+    const copy: any = array ? [] : {}
 
-    for (i = length; i-- !== 0; ) {
-      const key = keys[i]
+    let equalItems = 0
 
-      if (!equal(a[key], b[key], deep, depth + 1)) return false
+    for (let i = 0; i < bSize; i++) {
+      const key = array ? i : bItems[i]
+      copy[key] = replaceEqualDeep(a[key], b[key])
+      if (copy[key] === a[key]) {
+        equalItems++
+      }
     }
 
+    return aSize === bSize && equalItems === aSize ? a : copy
+  }
+
+  return b
+}
+
+export function isObject(a: unknown): boolean {
+  return a && typeof a === 'object' && !Array.isArray(a)
+}
+
+// Copied from: https://github.com/jonschlinkert/is-plain-object
+function isPlainObject(o: any): o is Object {
+  if (!hasObjectPrototype(o)) {
+    return false
+  }
+
+  // If has modified constructor
+  const ctor = o.constructor
+  if (typeof ctor === 'undefined') {
     return true
   }
 
-  // true if both NaN, false otherwise
-  // eslint-disable-next-line no-self-compare
-  return a !== a && b !== b
+  // If has modified prototype
+  const prot = ctor.prototype
+  if (!hasObjectPrototype(prot)) {
+    return false
+  }
+
+  // If constructor does not have an Object-specific method
+  if (!prot.hasOwnProperty('isPrototypeOf')) {
+    return false
+  }
+
+  // Most likely a plain Object
+  return true
+}
+
+function hasObjectPrototype(o: any): boolean {
+  return Object.prototype.toString.call(o) === '[object Object]'
 }
 
 export function getStatusProps<T extends QueryStatus>(status: T) {


Test patch:
diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx
index cb1e0ad59..c6c345c00 100644
--- a/src/core/tests/utils.test.tsx
+++ b/src/core/tests/utils.test.tsx
@@ -1,5 +1,5 @@
 import { setConsole, queryCache } from '../'
-import { deepEqual, shallowEqual } from '../utils'
+import { deepEqual, replaceEqualDeep } from '../utils'
 import { queryKey } from '../../react/tests/utils'
 
 describe('core/utils', () => {
@@ -42,63 +42,219 @@ describe('core/utils', () => {
       expect(deepEqual(a, b)).toEqual(false)
     })
 
-    it('should return `false` for different dates', () => {
+    it('return `false` for equal dates', () => {
       const date1 = new Date(2020, 3, 1)
-      const date2 = new Date(2020, 3, 2)
+      const date2 = new Date(2020, 3, 1)
       expect(deepEqual(date1, date2)).toEqual(false)
     })
+  })
 
-    it('return `true` for equal dates', () => {
-      const date1 = new Date(2020, 3, 1)
-      const date2 = new Date(2020, 3, 1)
-      expect(deepEqual(date1, date2)).toEqual(true)
+  describe('replaceEqualDeep', () => {
+    it('should return the previous value when the next value is an equal primitive', () => {
+      expect(replaceEqualDeep(1, 1)).toBe(1)
+      expect(replaceEqualDeep('1', '1')).toBe('1')
+      expect(replaceEqualDeep(true, true)).toBe(true)
+      expect(replaceEqualDeep(false, false)).toBe(false)
+      expect(replaceEqualDeep(null, null)).toBe(null)
+      expect(replaceEqualDeep(undefined, undefined)).toBe(undefined)
+    })
+    it('should return the next value when the previous value is a different value', () => {
+      const date1 = new Date()
+      const date2 = new Date()
+      expect(replaceEqualDeep(1, 0)).toBe(0)
+      expect(replaceEqualDeep(1, 2)).toBe(2)
+      expect(replaceEqualDeep('1', '2')).toBe('2')
+      expect(replaceEqualDeep(true, false)).toBe(false)
+      expect(replaceEqualDeep(false, true)).toBe(true)
+      expect(replaceEqualDeep(date1, date2)).toBe(date2)
     })
-  })
 
-  describe('shallowEqual', () => {
-    it('should return `true` for empty objects', () => {
-      expect(shallowEqual({}, {})).toEqual(true)
+    it('should return the next value when the previous value is a different type', () => {
+      const array = [1]
+      const object = { a: 'a' }
+      expect(replaceEqualDeep(0, undefined)).toBe(undefined)
+      expect(replaceEqualDeep(undefined, 0)).toBe(0)
+      expect(replaceEqualDeep(2, undefined)).toBe(undefined)
+      expect(replaceEqualDeep(undefined, 2)).toBe(2)
+      expect(replaceEqualDeep(undefined, null)).toBe(null)
+      expect(replaceEqualDeep(null, undefined)).toBe(undefined)
+      expect(replaceEqualDeep({}, undefined)).toBe(undefined)
+      expect(replaceEqualDeep([], undefined)).toBe(undefined)
+      expect(replaceEqualDeep(array, object)).toBe(object)
+      expect(replaceEqualDeep(object, array)).toBe(array)
     })
 
-    it('should return `true` for equal values', () => {
-      expect(shallowEqual(1, 1)).toEqual(true)
+    it('should return the previous value when the next value is an equal array', () => {
+      const prev = [1, 2]
+      const next = [1, 2]
+      expect(replaceEqualDeep(prev, next)).toBe(prev)
     })
 
-    it('should return `true` for equal arrays', () => {
-      expect(shallowEqual([1, 2], [1, 2])).toEqual(true)
+    it('should return a copy when the previous value is a different array subset', () => {
+      const prev = [1, 2]
+      const next = [1, 2, 3]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
     })
 
-    it('should return `true` for equal shallow objects', () => {
-      const a = { a: 'a', b: 'b' }
-      const b = { a: 'a', b: 'b' }
-      expect(shallowEqual(a, b)).toEqual(true)
+    it('should return a copy when the previous value is a different array superset', () => {
+      const prev = [1, 2, 3]
+      const next = [1, 2]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
     })
 
-    it('should return `true` for equal deep objects with same identities', () => {
-      const deep = { b: 'b' }
-      const a = { a: deep, c: 'c' }
-      const b = { a: deep, c: 'c' }
-      expect(shallowEqual(a, b)).toEqual(true)
+    it('should return the previous value when the next value is an equal empty array', () => {
+      const prev: any[] = []
+      const next: any[] = []
+      expect(replaceEqualDeep(prev, next)).toBe(prev)
     })
 
-    it('should return `false` for non equal values', () => {
-      expect(shallowEqual(1, 2)).toEqual(false)
+    it('should return the previous value when the next value is an equal empty object', () => {
+      const prev = {}
+      const next = {}
+      expect(replaceEqualDeep(prev, next)).toBe(prev)
     })
 
-    it('should return `false` for equal arrays', () => {
-      expect(shallowEqual([1, 2], [1, 3])).toEqual(false)
+    it('should return the previous value when the next value is an equal object', () => {
+      const prev = { a: 'a' }
+      const next = { a: 'a' }
+      expect(replaceEqualDeep(prev, next)).toBe(prev)
     })
 
-    it('should return `false` for non equal shallow objects', () => {
-      const a = { a: 'a', b: 'b' }
-      const b = { a: 'a', b: 'c' }
-      expect(shallowEqual(a, b)).toEqual(false)
+    it('should replace different values in objects', () => {
+      const prev = { a: { b: 'b' }, c: 'c' }
+      const next = { a: { b: 'b' }, c: 'd' }
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result.a).toBe(prev.a)
+      expect(result.c).toBe(next.c)
     })
 
-    it('should return `false` for equal deep objects with different identities', () => {
-      const a = { a: { b: 'b' }, c: 'c' }
-      const b = { a: { b: 'b' }, c: 'c' }
-      expect(shallowEqual(a, b)).toEqual(false)
+    it('should replace different values in arrays', () => {
+      const prev = [1, { a: 'a' }, { b: { b: 'b' } }, [1]] as const
+      const next = [1, { a: 'a' }, { b: { b: 'c' } }, [1]] as const
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result[0]).toBe(prev[0])
+      expect(result[1]).toBe(prev[1])
+      expect(result[2]).not.toBe(next[2])
+      expect(result[2].b.b).toBe(next[2].b.b)
+      expect(result[3]).toBe(prev[3])
+    })
+
+    it('should replace different values in arrays when the next value is a subset', () => {
+      const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
+      const next = [{ a: 'a' }, { b: 'b' }]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result[0]).toBe(prev[0])
+      expect(result[1]).toBe(prev[1])
+      expect(result[2]).toBeUndefined()
+    })
+
+    it('should replace different values in arrays when the next value is a superset', () => {
+      const prev = [{ a: 'a' }, { b: 'b' }]
+      const next = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result[0]).toBe(prev[0])
+      expect(result[1]).toBe(prev[1])
+      expect(result[2]).toBe(next[2])
+    })
+
+    it('should copy objects which are not arrays or objects', () => {
+      const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }, 1]
+      const next = [{ a: 'a' }, new Map(), { c: 'c' }, 2]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result[0]).toBe(prev[0])
+      expect(result[1]).toBe(next[1])
+      expect(result[2]).toBe(prev[2])
+      expect(result[3]).toBe(next[3])
+    })
+
+    it('should support equal objects which are not arrays or objects', () => {
+      const map = new Map()
+      const prev = [map, [1]]
+      const next = [map, [1]]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toBe(prev)
+    })
+
+    it('should support non equal objects which are not arrays or objects', () => {
+      const map1 = new Map()
+      const map2 = new Map()
+      const prev = [map1, [1]]
+      const next = [map2, [1]]
+      const result = replaceEqualDeep(prev, next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result[0]).toBe(next[0])
+      expect(result[1]).toBe(prev[1])
+    })
+
+    it('should replace all parent objects if some nested value changes', () => {
+      const prev = {
+        todo: { id: '1', meta: { createdAt: 0 }, state: { done: false } },
+        otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
+      }
+      const next = {
+        todo: { id: '1', meta: { createdAt: 0 }, state: { done: true } },
+        otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
+      }
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result.todo).not.toBe(prev.todo)
+      expect(result.todo).not.toBe(next.todo)
+      expect(result.todo.id).toBe(next.todo.id)
+      expect(result.todo.meta).toBe(prev.todo.meta)
+      expect(result.todo.state).not.toBe(next.todo.state)
+      expect(result.todo.state.done).toBe(next.todo.state.done)
+      expect(result.otherTodo).toBe(prev.otherTodo)
+    })
+
+    it('should replace all parent arrays if some nested value changes', () => {
+      const prev = {
+        todos: [
+          { id: '1', meta: { createdAt: 0 }, state: { done: false } },
+          { id: '2', meta: { createdAt: 0 }, state: { done: true } },
+        ],
+      }
+      const next = {
+        todos: [
+          { id: '1', meta: { createdAt: 0 }, state: { done: true } },
+          { id: '2', meta: { createdAt: 0 }, state: { done: true } },
+        ],
+      }
+      const result = replaceEqualDeep(prev, next)
+      expect(result).toEqual(next)
+      expect(result).not.toBe(prev)
+      expect(result).not.toBe(next)
+      expect(result.todos).not.toBe(prev.todos)
+      expect(result.todos).not.toBe(next.todos)
+      expect(result.todos[0]).not.toBe(prev.todos[0])
+      expect(result.todos[0]).not.toBe(next.todos[0])
+      expect(result.todos[0].id).toBe(next.todos[0].id)
+      expect(result.todos[0].meta).toBe(prev.todos[0].meta)
+      expect(result.todos[0].state).not.toBe(next.todos[0].state)
+      expect(result.todos[0].state.done).toBe(next.todos[0].state.done)
+      expect(result.todos[1]).toBe(prev.todos[1])
     })
   })
 })
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 0a7788e70..2110bceeb 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -214,6 +214,60 @@ describe('useQuery', () => {
     })
   })
 
+  it('should share equal data structures between query results', async () => {
+    const key = queryKey()
+
+    const result1 = [
+      { id: '1', done: false },
+      { id: '2', done: false },
+    ]
+
+    const result2 = [
+      { id: '1', done: false },
+      { id: '2', done: true },
+    ]
+
+    const states: QueryResult<typeof result1>[] = []
+
+    let count = 0
+
+    function Page() {
+      const state = useQuery(key, () => {
+        count++
+        return count === 1 ? result1 : result2
+      })
+
+      states.push(state)
+
+      const { refetch } = state
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          refetch()
+        }, 10)
+      }, [refetch])
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(4))
+
+    const todos = states[2].data!
+    const todo1 = todos[0]
+    const todo2 = todos[1]
+
+    const newTodos = states[3].data!
+    const newTodo1 = newTodos[0]
+    const newTodo2 = newTodos[1]
+
+    expect(todos).toEqual(result1)
+    expect(newTodos).toEqual(result2)
+    expect(newTodos).not.toBe(todos)
+    expect(newTodo1).toBe(todo1)
+    expect(newTodo2).not.toBe(todo2)
+  })
+
   // See https://github.com/tannerlinsley/react-query/issues/137
   it('should not override initial data in dependent queries', async () => {
     const key1 = queryKey()