我們已經理解 react-query 在什麼狀態下會怎麼取資料,如第一次結果回來前(loading) data 會是 undefined,而第二次發出請求時 data 會暫時給你前一次 cache 的值,直到請求成功,如此循環。

在這個前提下你大概會預期 query status 的 data 會在什麼情況下會觸發改變,在假設 cache 都存在的情況下,同一個 useQuery 相同 queryKey 不斷 refetch 以下情況會改變

  1. 第一次從 API 取到 data 時 undefined -> data
  2. 第二次重新 refetch 成功後拿到新值 oldData -> newData
  3. 第 N 次重新 refetch 成功後拿到新值 oldData -> newData
function App() {
  const status = useQuery({
    queryKey: ["status"],
    queryFn: fetchData,
  });
 
  useEffect(() => {
      console.log("data change", status.data);
  }, [status.data]);
  
  return (<div>
      <button type="button" onClick={() => status.refetch()}>
        refetch
      </button>
  </div>);
}

再假設另一種情境,我有一個 flag 當作 key 切換,我重複的來回切換 status 的 data 會怎麼觸發更新,一樣,這次也假設 cache 都存在的情況

  1. flag = false 情況下第一次從 API 取到 data 時 undefined -> data(false)
  2. flag 由 false 轉成 true 時重發 API 等待時間 data(false) -> undefined
  3. flag = true API 成功 undefined -> data(true)
  4. flag 由 true 轉成 false 時重發 API 等待時間 data(true) -> cache data(false)
  5. flag = false API 成功 cache data(false) -> newData(false)
  6. flag 由 false 轉成 true 時重發 API 等待時間 data(false) -> cache data(true)
  7. flag = true API 成功 cache data(true) -> newData(true)
  8. 在 cache 未消失的情況一直切換都會是 4 ~ 7 的循環
function App() {
  const [flag, setFlag] = useState(false);
 
  const status = useQuery({
    queryKey: ["status", flag],
    queryFn: fetchData,
  });
 
  useEffect(() => {
    console.log("data change", status.data, flag);
  }, [status.data]);
 
  return (
    <div>
      <button type="button" onClick={() => setFlag(!flag)}>
        click
      </button>
      <button type="button" onClick={() => status.refetch()}>
        refetch
      </button>
    </div>
  );
}

事情沒那麼簡單

當你真的實際去測我給的範例的時候,你會發現好像有些情況跟你認知不同,尤其是你自己做了一個模擬 API 的函數時

const fetchData = () => {
  return Promise.resolve({ data: { name: "content" } });
};

第一個範例在 refetch 情況下會無法觸發更新,而第二個範例在每次切轉預期會有兩次但變成一次。

你以為你的預期是錯的嘛?不,前面所有預期是都對的,只是 react query 會對資料作結構共享(structural sharing),因為這個原因你的資料在某些情況下 useEffect 會偵測不到 data 的更新

結構共享

比對新舊資料將相異的資料更新,但相同的資料保留舊參考(Object, Array),來舉幾個範例

結構相同但部份資訊不同

const oldData = {
    name: 'john',
    age: 20
}
 
const newData = {
    name: 'john',
    age: 30
}
 
const result = replaceEqualDeep(oldData, newData);
flowchart TB
	classDef changed stroke:#0f0
	
	subgraph result
		direction TB
		res[data]:::changed --> name[name]
		res[data] --> age[age]:::changed
	end


	subgraph "origin(oldData)"
		direction TB
		old[data] --> oldName[name]
		old[data] --> oldAge[age]
	end

綠框就是 oldData 與 newData 作結構共享後被異動的結構或值。

我們把結構弄複雜一點

const oldData = {
    name: 'john',
    age: 20,
    arr: [1, 2],
    subObj: {
        data: "content"
    }
}
 
const newData = {
    name: 'john',
    age: 20,
    arr: [1, 2, 3],
    subObj: {
        data: "content"
    }
}
 
const result = replaceEqualDeep(oldData, newData);
flowchart LR
	classDef changed stroke:#0f0
	
	subgraph result
		direction TB
		res[data]:::changed --> resName[name]
		res[data] --> resAge[age]
		res[data] --> resArr[arr]:::changed
		res[data] --> resSubObj[subObj]
		resSubObj --> resData[data]
	end


	subgraph "origin(oldData)"
		direction TB
		
		old[data] --> oldName[name]
		old[data] --> oldAge[age]
		old[data] --> oldArr[arr]
		old[data] --> oldSubObj[subObj]
		oldSubObj --> oldData[data]
	end

這樣我們可以看出在部份資料修改時整個父層結構都會被更新,詳細可以看 replaceEqualDeep 實作,或是參考測試案例

到此,剛剛的疑惑可以解開了,因為我們 API data 一直沒變,結構共享後的參考也會維持,所以 useEffect 會偵測不到更新。

哪些更新因為結構共享被省略了?

第一個範例

  1. 第一次從 API 取到 data 時 undefined -> data
  2. ❌ 第二次重新 refetch 成功後拿到新值 oldData -> newData
  3. ❌ 第 N 次重新 refetch 成功後拿到新值 oldData -> newData

第二個

  1. flag = false 情況下第一次從 API 取到 data 時 undefined -> data(false)
  2. flag 由 false 轉成 true 時重發 API 等待時間 data(false) -> undefined
  3. flag = true API 成功 undefined -> data(true)
  4. flag 由 true 轉成 false 時重發 API 等待時間 data(true) -> cache data(false)
  5. ❌ flag = false API 成功 cache data(false) -> newData(false)
  6. flag 由 false 轉成 true 時重發 API 等待時間 data(false) -> cache data(true)
  7. ❌ flag = true API 成功 cache data(true) -> newData(true)
  8. 在 cache 未消失的情況一直切換都會是 4 ~ 7 的循環

還是太天真了

今天在你的 useQuery 多了加了select 函數,情況又不一樣了,因為 react query 會自動再執行一次結構共享,只是時機點不同,所以一次的 data 更新總共會執行兩次,但在第一個範例你看不出差異,需要第二個範例才能察覺。

function App() {
  const [flag, setFlag] = useState(false);
 
  const status = useQuery({
    queryKey: ["status", flag],
    queryFn: fetchData,
    select: (v) => v // 新增 select 函數
  });
 
  useEffect(() => {
    console.log("data change", status.data, flag);
  }, [status.data]);
 
  return (
    <div>
      <button type="button" onClick={() => setFlag(!flag)}>
        click
      </button>
      <button type="button" onClick={() => status.refetch()}>
        refetch
      </button>
    </div>
  );
}

在試著去切換你會發現有哪些更新被省略了

  1. flag = false 情況下第一次從 API 取到 data 時 undefined -> data(false)
  2. flag 由 false 轉成 true 時重發 API 等待時間 data(false) -> undefined
  3. flag = true API 成功 undefined -> data(true)
  4. ❌ flag 由 true 轉成 false 時重發 API 等待時間 data(true) -> cache data(false)
  5. ❌ flag = false API 成功 cache data(false) -> newData(false)
  6. ❌ flag 由 false 轉成 true 時重發 API 等待時間 data(false) -> cache data(true)
  7. ❌ flag = true API 成功 cache data(true) -> newData(true)
  8. 在 cache 未消失的情況一直切換都會是 4 ~ 7 的循環

兩次結構共享時機點

第一次的結構共享會是 react-core 裡的 Query fetch API 拿到值的當下會做一次去更新狀態,第二次會從我準備要更新狀態根據你有沒有放 select 再做一次,否則就是直接替換。

flowchart TB
	subgraph setData
		one[replaceEqualDeep]
		one --> dispatchUpdate
	end
	dispatchUpdate --有 select--> two[replaceEqualDeep]
	dispatchUpdate --無 select--> replace
	two & replace --> newData[new data state]

那不帶 select 會使切換時 data 偵測到更新的原因是什麼?

首先要先知道,react-query 由 Observer 管理是否更新狀態,更新有可能會抓取 query client 不同的 Query 實體,所以並不是一個 Observer 永遠只配對同一個 Query 實體。

flowchart TB
	QueryClient --get target query--> Observer
	Observer --update--> state[React State]

因此 query key 的切換會導致 Observer 取到的 Query 實體不同,即便 Query class setData 本身會作結構共享,但實際 Observable 在更新 data 是兩個不同 Query 實體資料的切換,所以 data 會偵測到更新,如果是直接執行 refetch 就不會偵測到變更。

相關連結

重點參考函數

  • useBaseQuery
    • observer.getOptimisticResult
    • observer.getCurrentResult
    • observer.subscribe
    • observer.setOptions
  • queryObserver
    • setOptions
    • #executeFetch
    • updateResult
    • getOptimisticResult
    • createResult
  • query
    • fetch
    • setData